@co0ontty/wand 4.20.0 → 4.21.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/dist/build-info.json +3 -3
- package/dist/models.d.ts +49 -0
- package/dist/models.js +277 -46
- package/dist/server-settings-routes.d.ts +2 -2
- package/dist/server-settings-routes.js +6 -4
- package/dist/server-update-routes.d.ts +2 -2
- package/dist/server-update-routes.js +3 -2
- package/dist/server.d.ts +1 -1
- package/dist/server.js +21 -6
- package/dist/web-ui/content/styles.css +1 -1
- package/dist/web-ui/embedded-assets.d.ts +1 -1
- package/dist/web-ui/embedded-assets.js +2 -2
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"commit": "
|
|
3
|
-
"builtAt": "2026-07-
|
|
4
|
-
"version": "4.
|
|
2
|
+
"commit": "79972abbe91abdb5e1627ebb9eaba7b2e1ef8118",
|
|
3
|
+
"builtAt": "2026-07-22T05:37:40.056Z",
|
|
4
|
+
"version": "4.21.0",
|
|
5
5
|
"channel": "stable"
|
|
6
6
|
}
|
package/dist/models.d.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { ClaudeModelInfo } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The complete server-side model catalog. Keep this separate from the Claude
|
|
4
|
+
* verification cache: the former is a client-facing snapshot for every
|
|
5
|
+
* provider, whereas the latter records evidence from individual probes.
|
|
6
|
+
*/
|
|
7
|
+
export declare const MODEL_CATALOG_CACHE_KEY = "model-catalog-v1";
|
|
2
8
|
export interface ModelCacheStorage {
|
|
3
9
|
getConfigValue(key: string): string | null;
|
|
4
10
|
setConfigValue(key: string, value: string): void;
|
|
@@ -40,6 +46,21 @@ export interface ModelCache {
|
|
|
40
46
|
opencodeVersion: string | null;
|
|
41
47
|
refreshedAt: string;
|
|
42
48
|
}
|
|
49
|
+
/** Immutable-looking snapshot returned to API clients. */
|
|
50
|
+
export interface ModelCatalogSnapshot extends ModelCache {
|
|
51
|
+
/** SHA-256 of the catalog excluding `refreshedAt`. Changes only with content. */
|
|
52
|
+
revision: string;
|
|
53
|
+
}
|
|
54
|
+
export interface ModelCatalogRefreshResult extends ModelCatalogSnapshot {
|
|
55
|
+
/** True only when the persisted catalog content changed (or was first saved). */
|
|
56
|
+
changed: boolean;
|
|
57
|
+
/** Time this server-side refresh check ran; it is deliberately not persisted. */
|
|
58
|
+
checkedAt: string;
|
|
59
|
+
}
|
|
60
|
+
export interface ModelCatalogRefreshRequest {
|
|
61
|
+
/** Administrator-triggered refreshes may also validate Claude candidates. */
|
|
62
|
+
verifyClaudeCandidates?: boolean;
|
|
63
|
+
}
|
|
43
64
|
/**
|
|
44
65
|
* Parse `grok models` human-readable output:
|
|
45
66
|
*
|
|
@@ -61,5 +82,33 @@ export declare function parseQoderModels(stdout: string): ClaudeModelInfo[];
|
|
|
61
82
|
export declare function parseOpenCodeModels(stdout: string): ClaudeModelInfo[];
|
|
62
83
|
/** Parse the machine-readable model registry emitted by the installed Codex CLI. */
|
|
63
84
|
export declare function parseCodexModels(stdout: string): ClaudeModelInfo[];
|
|
85
|
+
/**
|
|
86
|
+
* Server-owned, persisted model directory.
|
|
87
|
+
*
|
|
88
|
+
* It deliberately has a tiny surface: clients read `snapshot`; only server
|
|
89
|
+
* jobs and an administrator route may call `refresh`. Each service instance
|
|
90
|
+
* owns its cache and single-flight lock, so test servers and multiple hosts in
|
|
91
|
+
* the same Node process cannot leak a catalog into one another.
|
|
92
|
+
*/
|
|
93
|
+
export declare class ModelCatalogService {
|
|
94
|
+
private readonly getOptions;
|
|
95
|
+
private cache;
|
|
96
|
+
private revision;
|
|
97
|
+
private hasPersistedSnapshot;
|
|
98
|
+
private refreshPromise;
|
|
99
|
+
private inFlightIncludesVerification;
|
|
100
|
+
constructor(getOptions: () => ModelRefreshOptions);
|
|
101
|
+
snapshot(): ModelCatalogSnapshot;
|
|
102
|
+
refresh(request?: ModelCatalogRefreshRequest): Promise<ModelCatalogRefreshResult>;
|
|
103
|
+
private performRefresh;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Compatibility helper for callers that only need a synchronous fallback
|
|
107
|
+
* catalog. It intentionally does not own or mutate process-global state.
|
|
108
|
+
*/
|
|
64
109
|
export declare function getCachedModels(options?: ModelRefreshOptions): ModelCache;
|
|
110
|
+
/**
|
|
111
|
+
* Compatibility helper for direct callers and unit tests. Server code should
|
|
112
|
+
* use `ModelCatalogService` so the result is persisted and diffed.
|
|
113
|
+
*/
|
|
65
114
|
export declare function refreshModels(options?: ModelRefreshOptions): Promise<ModelCache>;
|
package/dist/models.js
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { promisify } from "node:util";
|
|
3
4
|
import Anthropic from "@anthropic-ai/sdk";
|
|
4
5
|
import { buildChildEnv } from "./env-utils.js";
|
|
5
6
|
import { extractSemver } from "./version-utils.js";
|
|
6
7
|
const execFileAsync = promisify(execFile);
|
|
7
8
|
const CLAUDE_VERIFICATION_CACHE_KEY = "claude-model-verifications-v1";
|
|
9
|
+
/**
|
|
10
|
+
* The complete server-side model catalog. Keep this separate from the Claude
|
|
11
|
+
* verification cache: the former is a client-facing snapshot for every
|
|
12
|
+
* provider, whereas the latter records evidence from individual probes.
|
|
13
|
+
*/
|
|
14
|
+
export const MODEL_CATALOG_CACHE_KEY = "model-catalog-v1";
|
|
15
|
+
const MODEL_CATALOG_CACHE_VERSION = 1;
|
|
8
16
|
const CLAUDE_VERIFICATION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
9
17
|
const CLAUDE_PROBE_TIMEOUT_MS = 15_000;
|
|
10
18
|
const MAX_CLAUDE_MODEL_PROBES = 12;
|
|
@@ -45,9 +53,25 @@ const QODER_FALLBACK_MODELS = [
|
|
|
45
53
|
{ id: "performance", label: "Performance" },
|
|
46
54
|
{ id: "ultimate", label: "Ultimate" },
|
|
47
55
|
];
|
|
48
|
-
let cache = null;
|
|
49
56
|
function cloneModels(models) {
|
|
50
|
-
return models.map((model) => ({
|
|
57
|
+
return models.map((model) => ({
|
|
58
|
+
...model,
|
|
59
|
+
...(model.reasoningEfforts
|
|
60
|
+
? { reasoningEfforts: model.reasoningEfforts.map((level) => ({ ...level })) }
|
|
61
|
+
: {}),
|
|
62
|
+
}));
|
|
63
|
+
}
|
|
64
|
+
function cloneCache(cache) {
|
|
65
|
+
return {
|
|
66
|
+
models: cloneModels(cache.models),
|
|
67
|
+
codexModels: cloneModels(cache.codexModels),
|
|
68
|
+
opencodeModels: cloneModels(cache.opencodeModels),
|
|
69
|
+
grokModels: cloneModels(cache.grokModels),
|
|
70
|
+
qoderModels: cloneModels(cache.qoderModels),
|
|
71
|
+
claudeVersion: cache.claudeVersion,
|
|
72
|
+
opencodeVersion: cache.opencodeVersion,
|
|
73
|
+
refreshedAt: cache.refreshedAt,
|
|
74
|
+
};
|
|
51
75
|
}
|
|
52
76
|
function defaultCommandRunner(file, args, options) {
|
|
53
77
|
return execFileAsync(file, args, {
|
|
@@ -216,25 +240,13 @@ function createInitialCache(options) {
|
|
|
216
240
|
refreshedAt: now.toISOString(),
|
|
217
241
|
};
|
|
218
242
|
}
|
|
219
|
-
function refreshCachedClaudeModels(options) {
|
|
220
|
-
if (!cache)
|
|
221
|
-
return;
|
|
222
|
-
const now = options.now?.() ?? new Date();
|
|
223
|
-
cache.models = buildClaudeModels({
|
|
224
|
-
configuredClaudeModels: options.configuredClaudeModels,
|
|
225
|
-
existingModels: cache.models,
|
|
226
|
-
verifications: loadClaudeVerifications(options.storage),
|
|
227
|
-
claudeVersion: cache.claudeVersion,
|
|
228
|
-
now,
|
|
229
|
-
});
|
|
230
|
-
}
|
|
231
243
|
async function probeClaudeVersion(runner, env) {
|
|
232
244
|
try {
|
|
233
245
|
const { stdout } = await runner("claude", ["--version"], { env, timeout: 5000 });
|
|
234
|
-
return extractSemver(stdout) ?? (stdout.trim().slice(0, 64) || null);
|
|
246
|
+
return { ok: true, value: extractSemver(stdout) ?? (stdout.trim().slice(0, 64) || null) };
|
|
235
247
|
}
|
|
236
248
|
catch {
|
|
237
|
-
return
|
|
249
|
+
return { ok: false };
|
|
238
250
|
}
|
|
239
251
|
}
|
|
240
252
|
async function probeClaudeModel(id, runner, env) {
|
|
@@ -252,10 +264,10 @@ async function probeClaudeModel(id, runner, env) {
|
|
|
252
264
|
async function probeCodexModels(runner, env) {
|
|
253
265
|
try {
|
|
254
266
|
const { stdout } = await runner("codex", ["debug", "models"], { env, timeout: 8000 });
|
|
255
|
-
return parseCodexModels(stdout);
|
|
267
|
+
return { ok: true, value: parseCodexModels(stdout) };
|
|
256
268
|
}
|
|
257
269
|
catch {
|
|
258
|
-
return
|
|
270
|
+
return { ok: false };
|
|
259
271
|
}
|
|
260
272
|
}
|
|
261
273
|
async function probeOpenCode(runner, env) {
|
|
@@ -264,29 +276,32 @@ async function probeOpenCode(runner, env) {
|
|
|
264
276
|
runner("opencode", ["--version"], { env, timeout: 5000 }),
|
|
265
277
|
]);
|
|
266
278
|
const models = modelsResult.status === "fulfilled"
|
|
267
|
-
? parseOpenCodeModels(modelsResult.value.stdout)
|
|
268
|
-
:
|
|
279
|
+
? { ok: true, value: parseOpenCodeModels(modelsResult.value.stdout) }
|
|
280
|
+
: { ok: false };
|
|
269
281
|
const version = versionResult.status === "fulfilled"
|
|
270
|
-
?
|
|
271
|
-
|
|
282
|
+
? {
|
|
283
|
+
ok: true,
|
|
284
|
+
value: extractSemver(versionResult.value.stdout) ?? (versionResult.value.stdout.trim().slice(0, 64) || null),
|
|
285
|
+
}
|
|
286
|
+
: { ok: false };
|
|
272
287
|
return { models, version };
|
|
273
288
|
}
|
|
274
289
|
async function probeGrokModels(runner, env) {
|
|
275
290
|
try {
|
|
276
291
|
const { stdout } = await runner("grok", ["models"], { env, timeout: 8000 });
|
|
277
|
-
return parseGrokModels(stdout);
|
|
292
|
+
return { ok: true, value: parseGrokModels(stdout) };
|
|
278
293
|
}
|
|
279
294
|
catch {
|
|
280
|
-
return
|
|
295
|
+
return { ok: false };
|
|
281
296
|
}
|
|
282
297
|
}
|
|
283
298
|
async function probeQoderModels(runner, env) {
|
|
284
299
|
try {
|
|
285
300
|
const { stdout } = await runner("qodercli", ["--list-models"], { env, timeout: 8000 });
|
|
286
|
-
return parseQoderModels(stdout);
|
|
301
|
+
return { ok: true, value: parseQoderModels(stdout) };
|
|
287
302
|
}
|
|
288
303
|
catch {
|
|
289
|
-
return
|
|
304
|
+
return { ok: false };
|
|
290
305
|
}
|
|
291
306
|
}
|
|
292
307
|
function createOfficialModelsApi(apiKey) {
|
|
@@ -298,7 +313,7 @@ function createOfficialModelsApi(apiKey) {
|
|
|
298
313
|
async function listClaudeModelsFromApi(options, env) {
|
|
299
314
|
const apiKey = options.apiKey?.trim() || env.ANTHROPIC_API_KEY?.trim();
|
|
300
315
|
if (!apiKey)
|
|
301
|
-
return
|
|
316
|
+
return { ok: false };
|
|
302
317
|
try {
|
|
303
318
|
const api = options.modelsApi ?? createOfficialModelsApi(apiKey);
|
|
304
319
|
const models = [];
|
|
@@ -307,10 +322,10 @@ async function listClaudeModelsFromApi(options, env) {
|
|
|
307
322
|
if (id)
|
|
308
323
|
models.push({ id, ...(typeof model.display_name === "string" ? { display_name: model.display_name } : {}) });
|
|
309
324
|
}
|
|
310
|
-
return models;
|
|
325
|
+
return { ok: true, value: models };
|
|
311
326
|
}
|
|
312
327
|
catch {
|
|
313
|
-
return
|
|
328
|
+
return { ok: false };
|
|
314
329
|
}
|
|
315
330
|
}
|
|
316
331
|
function probePriority(model) {
|
|
@@ -498,20 +513,152 @@ function formatCodexModelLabel(model) {
|
|
|
498
513
|
? `${model.display_name} · ${model.slug}`
|
|
499
514
|
: model.slug;
|
|
500
515
|
}
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
516
|
+
function catalogRevision(cache) {
|
|
517
|
+
// `refreshedAt` answers "when did content last change", so it must not
|
|
518
|
+
// create a false change by itself. JSON keeps the provider/model order that
|
|
519
|
+
// the CLIs publish; that order is part of the client-facing catalog.
|
|
520
|
+
const content = JSON.stringify({
|
|
521
|
+
models: cache.models,
|
|
522
|
+
codexModels: cache.codexModels,
|
|
523
|
+
opencodeModels: cache.opencodeModels,
|
|
524
|
+
grokModels: cache.grokModels,
|
|
525
|
+
qoderModels: cache.qoderModels,
|
|
526
|
+
claudeVersion: cache.claudeVersion,
|
|
527
|
+
opencodeVersion: cache.opencodeVersion,
|
|
528
|
+
});
|
|
529
|
+
return createHash("sha256").update(content).digest("hex");
|
|
530
|
+
}
|
|
531
|
+
function isRecord(value) {
|
|
532
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
533
|
+
}
|
|
534
|
+
function safePersistedString(value, maxLength = 512) {
|
|
535
|
+
if (typeof value !== "string")
|
|
536
|
+
return null;
|
|
537
|
+
const trimmed = value.trim();
|
|
538
|
+
return trimmed && trimmed.length <= maxLength ? trimmed : null;
|
|
539
|
+
}
|
|
540
|
+
function parsePersistedModelInfo(value) {
|
|
541
|
+
if (!isRecord(value))
|
|
542
|
+
return null;
|
|
543
|
+
const id = safePersistedString(value.id, 128);
|
|
544
|
+
const label = safePersistedString(value.label);
|
|
545
|
+
if (!id || !QODER_MODEL_ID_PATTERN.test(id) || !label)
|
|
546
|
+
return null;
|
|
547
|
+
const source = value.source === "builtin" || value.source === "configured"
|
|
548
|
+
|| value.source === "verified-cache" || value.source === "models-api"
|
|
549
|
+
? value.source
|
|
550
|
+
: undefined;
|
|
551
|
+
const availability = value.availability === "default" || value.availability === "candidate"
|
|
552
|
+
|| value.availability === "verified" || value.availability === "stale"
|
|
553
|
+
? value.availability
|
|
554
|
+
: undefined;
|
|
555
|
+
const reasoningEfforts = Array.isArray(value.reasoningEfforts)
|
|
556
|
+
? value.reasoningEfforts.flatMap((entry) => {
|
|
557
|
+
if (!isRecord(entry))
|
|
558
|
+
return [];
|
|
559
|
+
const effort = safePersistedString(entry.effort, 128);
|
|
560
|
+
if (!effort)
|
|
561
|
+
return [];
|
|
562
|
+
const description = safePersistedString(entry.description);
|
|
563
|
+
return [{ effort, ...(description ? { description } : {}) }];
|
|
564
|
+
})
|
|
565
|
+
: undefined;
|
|
566
|
+
const note = safePersistedString(value.note);
|
|
567
|
+
const lastVerifiedAt = safePersistedString(value.lastVerifiedAt, 64);
|
|
568
|
+
const verifiedWithClaudeVersion = safePersistedString(value.verifiedWithClaudeVersion, 128);
|
|
569
|
+
const defaultReasoningEffort = safePersistedString(value.defaultReasoningEffort, 128);
|
|
570
|
+
return {
|
|
571
|
+
id,
|
|
572
|
+
label,
|
|
573
|
+
...(typeof value.alias === "boolean" ? { alias: value.alias } : {}),
|
|
574
|
+
...(source ? { source } : {}),
|
|
575
|
+
...(availability ? { availability } : {}),
|
|
576
|
+
...(note ? { note } : {}),
|
|
577
|
+
...(lastVerifiedAt && !Number.isNaN(Date.parse(lastVerifiedAt)) ? { lastVerifiedAt } : {}),
|
|
578
|
+
...(verifiedWithClaudeVersion ? { verifiedWithClaudeVersion } : {}),
|
|
579
|
+
...(reasoningEfforts?.length ? { reasoningEfforts } : {}),
|
|
580
|
+
...(defaultReasoningEffort ? { defaultReasoningEffort } : {}),
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
function parsePersistedModelList(value) {
|
|
584
|
+
if (!Array.isArray(value))
|
|
585
|
+
return null;
|
|
586
|
+
const result = [];
|
|
587
|
+
const seen = new Set();
|
|
588
|
+
for (const entry of value) {
|
|
589
|
+
const model = parsePersistedModelInfo(entry);
|
|
590
|
+
if (!model || seen.has(model.id))
|
|
591
|
+
return null;
|
|
592
|
+
seen.add(model.id);
|
|
593
|
+
result.push(model);
|
|
594
|
+
}
|
|
595
|
+
return result;
|
|
596
|
+
}
|
|
597
|
+
function parsePersistedModelCatalog(value) {
|
|
598
|
+
if (!isRecord(value) || value.version !== MODEL_CATALOG_CACHE_VERSION || !isRecord(value.catalog))
|
|
599
|
+
return null;
|
|
600
|
+
const catalog = value.catalog;
|
|
601
|
+
const models = parsePersistedModelList(catalog.models);
|
|
602
|
+
const codexModels = parsePersistedModelList(catalog.codexModels);
|
|
603
|
+
const opencodeModels = parsePersistedModelList(catalog.opencodeModels);
|
|
604
|
+
const grokModels = parsePersistedModelList(catalog.grokModels);
|
|
605
|
+
const qoderModels = parsePersistedModelList(catalog.qoderModels);
|
|
606
|
+
const refreshedAt = safePersistedString(catalog.refreshedAt, 64);
|
|
607
|
+
if (!models || !codexModels || !opencodeModels || !grokModels || !qoderModels
|
|
608
|
+
|| !refreshedAt || Number.isNaN(Date.parse(refreshedAt))) {
|
|
609
|
+
return null;
|
|
610
|
+
}
|
|
611
|
+
const nullableVersion = (field) => field === null ? null : safePersistedString(field, 128) ?? undefined;
|
|
612
|
+
const claudeVersion = nullableVersion(catalog.claudeVersion);
|
|
613
|
+
const opencodeVersion = nullableVersion(catalog.opencodeVersion);
|
|
614
|
+
if (claudeVersion === undefined || opencodeVersion === undefined)
|
|
615
|
+
return null;
|
|
616
|
+
const parsedCatalog = {
|
|
617
|
+
models,
|
|
618
|
+
codexModels,
|
|
619
|
+
opencodeModels,
|
|
620
|
+
grokModels,
|
|
621
|
+
qoderModels,
|
|
622
|
+
claudeVersion,
|
|
623
|
+
opencodeVersion,
|
|
624
|
+
refreshedAt,
|
|
625
|
+
};
|
|
626
|
+
// A stale/missing revision should not make a previously good snapshot
|
|
627
|
+
// unreadable. It is recomputed instead of trusted.
|
|
628
|
+
return {
|
|
629
|
+
version: MODEL_CATALOG_CACHE_VERSION,
|
|
630
|
+
revision: catalogRevision(parsedCatalog),
|
|
631
|
+
catalog: parsedCatalog,
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
function loadPersistedModelCatalog(storage) {
|
|
635
|
+
if (!storage)
|
|
636
|
+
return null;
|
|
637
|
+
const raw = storage.getConfigValue(MODEL_CATALOG_CACHE_KEY);
|
|
638
|
+
if (!raw)
|
|
639
|
+
return null;
|
|
640
|
+
try {
|
|
641
|
+
return parsePersistedModelCatalog(JSON.parse(raw));
|
|
504
642
|
}
|
|
505
|
-
|
|
506
|
-
|
|
643
|
+
catch {
|
|
644
|
+
return null;
|
|
507
645
|
}
|
|
508
|
-
return cache;
|
|
509
646
|
}
|
|
510
|
-
|
|
647
|
+
function savePersistedModelCatalog(storage, cache, revision) {
|
|
648
|
+
if (!storage)
|
|
649
|
+
return;
|
|
650
|
+
const persisted = {
|
|
651
|
+
version: MODEL_CATALOG_CACHE_VERSION,
|
|
652
|
+
revision,
|
|
653
|
+
catalog: cloneCache(cache),
|
|
654
|
+
};
|
|
655
|
+
storage.setConfigValue(MODEL_CATALOG_CACHE_KEY, JSON.stringify(persisted));
|
|
656
|
+
}
|
|
657
|
+
async function discoverModelCache(options, previous) {
|
|
511
658
|
const now = options.now?.() ?? new Date();
|
|
512
659
|
const env = resolveProbeEnv(options);
|
|
513
660
|
const runner = options.commandRunner ?? defaultCommandRunner;
|
|
514
|
-
const [
|
|
661
|
+
const [claudeVersionProbe, codexProbe, opencodeProbe, grokProbe, qoderProbe, apiProbe] = await Promise.all([
|
|
515
662
|
probeClaudeVersion(runner, env),
|
|
516
663
|
probeCodexModels(runner, env),
|
|
517
664
|
probeOpenCode(runner, env),
|
|
@@ -519,10 +666,14 @@ export async function refreshModels(options = {}) {
|
|
|
519
666
|
probeQoderModels(runner, env),
|
|
520
667
|
listClaudeModelsFromApi(options, env),
|
|
521
668
|
]);
|
|
669
|
+
const claudeVersion = claudeVersionProbe.ok ? claudeVersionProbe.value : previous.claudeVersion;
|
|
522
670
|
const priorVerifications = loadClaudeVerifications(options.storage);
|
|
671
|
+
// A failed Models API request is not evidence that its prior models vanished.
|
|
672
|
+
// Keep the last good candidate set until a successful catalog request says
|
|
673
|
+
// otherwise; configured and verification-backed candidates are merged below.
|
|
523
674
|
const initialModels = buildClaudeModels({
|
|
524
675
|
configuredClaudeModels: options.configuredClaudeModels,
|
|
525
|
-
apiModels,
|
|
676
|
+
...(apiProbe.ok ? { apiModels: apiProbe.value } : { existingModels: previous.models }),
|
|
526
677
|
verifications: priorVerifications,
|
|
527
678
|
claudeVersion,
|
|
528
679
|
now,
|
|
@@ -533,21 +684,101 @@ export async function refreshModels(options = {}) {
|
|
|
533
684
|
const verifications = mergeVerifications(priorVerifications, initialModels, verifiedIds, claudeVersion, now);
|
|
534
685
|
if (verifiedIds.size > 0)
|
|
535
686
|
saveClaudeVerifications(options.storage, verifications);
|
|
536
|
-
|
|
687
|
+
return {
|
|
537
688
|
models: buildClaudeModels({
|
|
538
689
|
configuredClaudeModels: options.configuredClaudeModels,
|
|
539
|
-
apiModels,
|
|
690
|
+
...(apiProbe.ok ? { apiModels: apiProbe.value } : { existingModels: previous.models }),
|
|
540
691
|
verifications,
|
|
541
692
|
claudeVersion,
|
|
542
693
|
now,
|
|
543
694
|
}),
|
|
544
|
-
codexModels,
|
|
545
|
-
opencodeModels:
|
|
546
|
-
grokModels,
|
|
547
|
-
qoderModels,
|
|
695
|
+
codexModels: codexProbe.ok ? codexProbe.value : cloneModels(previous.codexModels),
|
|
696
|
+
opencodeModels: opencodeProbe.models.ok ? opencodeProbe.models.value : cloneModels(previous.opencodeModels),
|
|
697
|
+
grokModels: grokProbe.ok ? grokProbe.value : cloneModels(previous.grokModels),
|
|
698
|
+
qoderModels: qoderProbe.ok ? qoderProbe.value : cloneModels(previous.qoderModels),
|
|
548
699
|
claudeVersion,
|
|
549
|
-
opencodeVersion:
|
|
700
|
+
opencodeVersion: opencodeProbe.version.ok ? opencodeProbe.version.value : previous.opencodeVersion,
|
|
550
701
|
refreshedAt: now.toISOString(),
|
|
551
702
|
};
|
|
552
|
-
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Server-owned, persisted model directory.
|
|
706
|
+
*
|
|
707
|
+
* It deliberately has a tiny surface: clients read `snapshot`; only server
|
|
708
|
+
* jobs and an administrator route may call `refresh`. Each service instance
|
|
709
|
+
* owns its cache and single-flight lock, so test servers and multiple hosts in
|
|
710
|
+
* the same Node process cannot leak a catalog into one another.
|
|
711
|
+
*/
|
|
712
|
+
export class ModelCatalogService {
|
|
713
|
+
getOptions;
|
|
714
|
+
cache;
|
|
715
|
+
revision;
|
|
716
|
+
hasPersistedSnapshot;
|
|
717
|
+
refreshPromise = null;
|
|
718
|
+
inFlightIncludesVerification = false;
|
|
719
|
+
constructor(getOptions) {
|
|
720
|
+
this.getOptions = getOptions;
|
|
721
|
+
const initialOptions = getOptions();
|
|
722
|
+
const persisted = loadPersistedModelCatalog(initialOptions.storage);
|
|
723
|
+
this.cache = persisted ? cloneCache(persisted.catalog) : createInitialCache(initialOptions);
|
|
724
|
+
this.revision = persisted?.revision ?? catalogRevision(this.cache);
|
|
725
|
+
this.hasPersistedSnapshot = persisted !== null;
|
|
726
|
+
}
|
|
727
|
+
snapshot() {
|
|
728
|
+
return { ...cloneCache(this.cache), revision: this.revision };
|
|
729
|
+
}
|
|
730
|
+
refresh(request = {}) {
|
|
731
|
+
const verifyClaudeCandidates = request.verifyClaudeCandidates === true;
|
|
732
|
+
if (this.refreshPromise) {
|
|
733
|
+
const sharedRefresh = this.refreshPromise;
|
|
734
|
+
const sharedIncludesVerification = this.inFlightIncludesVerification;
|
|
735
|
+
return sharedRefresh.then((result) => verifyClaudeCandidates && !sharedIncludesVerification
|
|
736
|
+
? this.refresh({ verifyClaudeCandidates: true })
|
|
737
|
+
: result);
|
|
738
|
+
}
|
|
739
|
+
this.inFlightIncludesVerification = verifyClaudeCandidates;
|
|
740
|
+
const refresh = this.performRefresh({ verifyClaudeCandidates });
|
|
741
|
+
this.refreshPromise = refresh;
|
|
742
|
+
return refresh.finally(() => {
|
|
743
|
+
if (this.refreshPromise === refresh) {
|
|
744
|
+
this.refreshPromise = null;
|
|
745
|
+
this.inFlightIncludesVerification = false;
|
|
746
|
+
}
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
async performRefresh(request) {
|
|
750
|
+
const baseOptions = this.getOptions();
|
|
751
|
+
const options = {
|
|
752
|
+
...baseOptions,
|
|
753
|
+
verifyClaudeCandidates: request.verifyClaudeCandidates === true,
|
|
754
|
+
};
|
|
755
|
+
const checkedAt = (options.now?.() ?? new Date()).toISOString();
|
|
756
|
+
const discovered = await discoverModelCache(options, this.cache);
|
|
757
|
+
const discoveredRevision = catalogRevision(discovered);
|
|
758
|
+
const changed = !this.hasPersistedSnapshot || discoveredRevision !== this.revision;
|
|
759
|
+
if (changed) {
|
|
760
|
+
// The timestamp is only advanced with a meaningful catalog revision.
|
|
761
|
+
discovered.refreshedAt = checkedAt;
|
|
762
|
+
this.cache = cloneCache(discovered);
|
|
763
|
+
this.revision = catalogRevision(this.cache);
|
|
764
|
+
savePersistedModelCatalog(options.storage, this.cache, this.revision);
|
|
765
|
+
this.hasPersistedSnapshot = Boolean(options.storage);
|
|
766
|
+
}
|
|
767
|
+
return { ...this.snapshot(), changed, checkedAt };
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Compatibility helper for callers that only need a synchronous fallback
|
|
772
|
+
* catalog. It intentionally does not own or mutate process-global state.
|
|
773
|
+
*/
|
|
774
|
+
export function getCachedModels(options = {}) {
|
|
775
|
+
const persisted = loadPersistedModelCatalog(options.storage);
|
|
776
|
+
return cloneCache(persisted?.catalog ?? createInitialCache(options));
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Compatibility helper for direct callers and unit tests. Server code should
|
|
780
|
+
* use `ModelCatalogService` so the result is persisted and diffed.
|
|
781
|
+
*/
|
|
782
|
+
export async function refreshModels(options = {}) {
|
|
783
|
+
return discoverModelCache(options, createInitialCache(options));
|
|
553
784
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Express, Request, RequestHandler } from "express";
|
|
2
|
-
import {
|
|
2
|
+
import type { ModelCatalogService } from "./models.js";
|
|
3
3
|
import { type RuntimeConfigState } from "./runtime-config.js";
|
|
4
4
|
import type { WandStorage } from "./storage.js";
|
|
5
5
|
import type { WandConfig } from "./types.js";
|
|
@@ -33,7 +33,7 @@ export interface ServerSettingsRoutesDependencies {
|
|
|
33
33
|
} | null;
|
|
34
34
|
getUpdateChannel(): "stable" | "beta";
|
|
35
35
|
getDistributionSettings(): Promise<SettingsDistributionPayload>;
|
|
36
|
-
|
|
36
|
+
modelCatalog: ModelCatalogService;
|
|
37
37
|
resolveAppConnectCode(req: Request): {
|
|
38
38
|
code: string;
|
|
39
39
|
url: string;
|
|
@@ -4,7 +4,6 @@ import { buildChildEnv } from "./env-utils.js";
|
|
|
4
4
|
import { getErrorMessage } from "./error-utils.js";
|
|
5
5
|
import { asyncRoute } from "./express-async.js";
|
|
6
6
|
import { getProviderDefaultModels, PREFERENCE_KEYS, saveConfig, validateCommitAiConfig, writePreferenceToStorage, } from "./config.js";
|
|
7
|
-
import { getCachedModels, refreshModels } from "./models.js";
|
|
8
7
|
import { DEPLOYMENT_CONFIG_KEYS } from "./runtime-config.js";
|
|
9
8
|
import { discoverCliSystemAiConfigs, normalizeSystemAiConfig } from "./system-ai.js";
|
|
10
9
|
function publicConfig(config) {
|
|
@@ -271,7 +270,10 @@ export function registerSettingsRoutes(app, deps) {
|
|
|
271
270
|
}
|
|
272
271
|
}));
|
|
273
272
|
app.get("/api/models", (_req, res) => {
|
|
274
|
-
|
|
273
|
+
// Every client reads the server's persisted snapshot. Avoid browser/proxy
|
|
274
|
+
// cache races after an administrator or scheduled server refresh.
|
|
275
|
+
res.set("Cache-Control", "no-store");
|
|
276
|
+
const cached = deps.modelCatalog.snapshot();
|
|
275
277
|
const defaults = getProviderDefaultModels(config);
|
|
276
278
|
res.json({
|
|
277
279
|
...cached,
|
|
@@ -283,9 +285,9 @@ export function registerSettingsRoutes(app, deps) {
|
|
|
283
285
|
defaultModels: defaults,
|
|
284
286
|
});
|
|
285
287
|
});
|
|
286
|
-
app.post("/api/models/refresh", asyncRoute(async (_req, res) => {
|
|
288
|
+
app.post("/api/models/refresh", requireAdmin, asyncRoute(async (_req, res) => {
|
|
287
289
|
try {
|
|
288
|
-
const refreshed = await
|
|
290
|
+
const refreshed = await deps.modelCatalog.refresh({ verifyClaudeCandidates: true });
|
|
289
291
|
const defaults = getProviderDefaultModels(config);
|
|
290
292
|
res.json({
|
|
291
293
|
...refreshed,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Express, RequestHandler } from "express";
|
|
2
|
-
import {
|
|
2
|
+
import type { ModelCatalogService } from "./models.js";
|
|
3
3
|
import type { PackageUpdateInfo, UpdateChannel } from "./npm-update-utils.js";
|
|
4
4
|
import { type ProviderCliUpdateStatus } from "./provider-cli-updater.js";
|
|
5
5
|
import type { WandStorage } from "./storage.js";
|
|
@@ -46,7 +46,7 @@ export interface AdminUpdateRoutesDependencies {
|
|
|
46
46
|
androidApk: Record<string, unknown>;
|
|
47
47
|
macosDmg: Record<string, unknown>;
|
|
48
48
|
}>;
|
|
49
|
-
|
|
49
|
+
modelCatalog: ModelCatalogService;
|
|
50
50
|
getUpdateChannel(): UpdateChannel;
|
|
51
51
|
checkLatestPackageVersion(channel: UpdateChannel, forceRefresh?: boolean): Promise<PackageUpdateInfo>;
|
|
52
52
|
buildInfo: {
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { getErrorMessage } from "./error-utils.js";
|
|
2
2
|
import { asyncRoute } from "./express-async.js";
|
|
3
|
-
import { refreshModels } from "./models.js";
|
|
4
3
|
import { checkProviderCliUpdates, updateProviderClis, verifyProviderCliUpdateResults, } from "./provider-cli-updater.js";
|
|
5
4
|
import { streamFileWithRange } from "./server-file-routes.js";
|
|
6
5
|
import { compareApkInstallOrder, compareSemver } from "./version-utils.js";
|
|
@@ -137,7 +136,9 @@ export function registerAdminUpdateRoutes(app, deps) {
|
|
|
137
136
|
});
|
|
138
137
|
const after = await refreshProviderCliUpdateState(state, config);
|
|
139
138
|
const results = verifyProviderCliUpdateResults(commandResults, after.items);
|
|
140
|
-
|
|
139
|
+
// Model discovery is server-owned and persisted; clients only consume
|
|
140
|
+
// the latest snapshot after a CLI update.
|
|
141
|
+
void deps.modelCatalog.refresh().catch(() => { });
|
|
141
142
|
res.json({ ok: results.every((item) => item.ok), results, ...after, autoUpdate: storage.getConfigValue("autoUpdateProviderClis") === "true" });
|
|
142
143
|
}
|
|
143
144
|
catch (error) {
|
package/dist/server.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { AuthService } from "./auth.js";
|
|
2
|
-
import { ModelRefreshOptions } from "./models.js";
|
|
2
|
+
import { type ModelRefreshOptions } from "./models.js";
|
|
3
3
|
import { ProcessManager } from "./process-manager.js";
|
|
4
4
|
import { StructuredSessionManager } from "./structured-session-manager.js";
|
|
5
5
|
import { type PathRepairResult } from "./path-repair.js";
|