@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.
@@ -1,6 +1,6 @@
1
1
  {
2
- "commit": "21ce9fc5eba74a26ce11a25d5542fb0339957cf7",
3
- "builtAt": "2026-07-21T00:47:10.060Z",
4
- "version": "4.20.0",
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) => ({ ...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 null;
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 cloneModels(CODEX_FALLBACK_MODELS);
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
- : cloneModels(OPENCODE_FALLBACK_MODELS);
279
+ ? { ok: true, value: parseOpenCodeModels(modelsResult.value.stdout) }
280
+ : { ok: false };
269
281
  const version = versionResult.status === "fulfilled"
270
- ? extractSemver(versionResult.value.stdout) ?? (versionResult.value.stdout.trim().slice(0, 64) || null)
271
- : null;
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 cloneModels(GROK_FALLBACK_MODELS);
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 cloneModels(QODER_FALLBACK_MODELS);
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
- export function getCachedModels(options = {}) {
502
- if (!cache) {
503
- cache = createInitialCache(options);
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
- else if (options.storage || options.configuredClaudeModels) {
506
- refreshCachedClaudeModels(options);
643
+ catch {
644
+ return null;
507
645
  }
508
- return cache;
509
646
  }
510
- export async function refreshModels(options = {}) {
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 [claudeVersion, codexModels, opencode, grokModels, qoderModels, apiModels] = await Promise.all([
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
- cache = {
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: opencode.models,
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: opencode.version,
700
+ opencodeVersion: opencodeProbe.version.ok ? opencodeProbe.version.value : previous.opencodeVersion,
550
701
  refreshedAt: now.toISOString(),
551
702
  };
552
- return cache;
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 { type ModelRefreshOptions } from "./models.js";
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
- getModelRefreshOptions(): ModelRefreshOptions;
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
- const cached = getCachedModels(deps.getModelRefreshOptions());
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 refreshModels({ ...deps.getModelRefreshOptions(), verifyClaudeCandidates: true });
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 { type ModelRefreshOptions } from "./models.js";
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
- getModelRefreshOptions(): ModelRefreshOptions;
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
- void refreshModels(deps.getModelRefreshOptions()).catch(() => { });
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";