dsh-plugin-subscriptions 0.2.0 → 0.3.1

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/README.md CHANGED
@@ -14,6 +14,10 @@ Logged-in providers join the session model picker with their live model catalogs
14
14
 
15
15
  ![Model picker with subscription models](https://raw.githubusercontent.com/V1ki/dsh-plugin-subscriptions/main/docs/images/model-picker.png)
16
16
 
17
+ Models that advertise reasoning levels get an **Effort** selector in the same menu — Codex models, and Grok 4.6 / 4.5 (levels and defaults come from each provider's live catalog, not a hardcoded list):
18
+
19
+ ![Reasoning effort selector](https://raw.githubusercontent.com/V1ki/dsh-plugin-subscriptions/main/docs/images/model-effort.png)
20
+
17
21
  The `image_generate` tool renders its result inline in the conversation:
18
22
 
19
23
  ![image_generate renders the image inline](https://raw.githubusercontent.com/V1ki/dsh-plugin-subscriptions/main/docs/images/image-generate-inline.png)
@@ -24,7 +28,7 @@ The `image_generate` tool renders its result inline in the conversation:
24
28
  |----------|-------------------|--------|
25
29
  | `codex` | ChatGPT Plus/Pro | live catalog from `chatgpt.com/backend-api/codex/models` |
26
30
  | `claude` | Claude Pro/Max | claude-opus-4-5, claude-sonnet-4-5, claude-haiku-4-5 |
27
- | `grok` | X Premium (xAI) | live catalog from `api.x.ai/v1/models` (chat models only) |
31
+ | `grok` | X Premium (xAI) | live catalog from `api.x.ai/v1/models` (chat models only); reasoning efforts from the Grok CLI catalog (`cli-chat-proxy.grok.com/v1/models`) |
28
32
 
29
33
  Only logged-in providers appear in the session model picker; the lists above refresh on login/logout. Vision-capable models declare `['text', 'image']` input modalities, and image content is translated to each provider's wire format.
30
34
 
@@ -73,6 +77,18 @@ cp overlay.example.yml overlay.yml # then edit the name: to this checkout's ab
73
77
  dsh --profile headless --patch <checkout>/overlay.yml "your task"
74
78
  ```
75
79
 
80
+ ## Update
81
+
82
+ Installed from npm:
83
+
84
+ ```sh
85
+ dsh plugin --profile web update --latest dsh-plugin-subscriptions
86
+ ```
87
+
88
+ Installed from GitHub: re-run the same `add github:V1ki/dsh-plugin-subscriptions` command — it re-fetches the sources and rebuilds. A linked local checkout just needs `git pull && pnpm build` in the checkout.
89
+
90
+ Either way, restart `dsh web` afterwards so the new version loads.
91
+
76
92
  ## Use
77
93
 
78
94
  1. `dsh web`, open the printed URL.
package/README.zh.md CHANGED
@@ -14,6 +14,10 @@
14
14
 
15
15
  ![模型选择器中的订阅模型](https://raw.githubusercontent.com/V1ki/dsh-plugin-subscriptions/main/docs/images/model-picker.png)
16
16
 
17
+ 声明了推理等级的模型会在同一菜单里多出**推理等级**选择 —— Codex 系列模型,以及 Grok 4.6 / 4.5(档位和默认值来自各 provider 的实时目录,不是硬编码列表):
18
+
19
+ ![推理等级选择器](https://raw.githubusercontent.com/V1ki/dsh-plugin-subscriptions/main/docs/images/model-effort.png)
20
+
17
21
  `image_generate` 工具生成的图片直接内联显示在对话里:
18
22
 
19
23
  ![image_generate 内联显示生成的图片](https://raw.githubusercontent.com/V1ki/dsh-plugin-subscriptions/main/docs/images/image-generate-inline.png)
@@ -24,7 +28,7 @@
24
28
  |----------|------------------|------|
25
29
  | `codex` | ChatGPT Plus/Pro | 从 `chatgpt.com/backend-api/codex/models` 实时获取 |
26
30
  | `claude` | Claude Pro/Max | claude-opus-4-5、claude-sonnet-4-5、claude-haiku-4-5 |
27
- | `grok` | X Premium (xAI) | 从 `api.x.ai/v1/models` 实时获取(仅对话模型) |
31
+ | `grok` | X Premium (xAI) | 从 `api.x.ai/v1/models` 实时获取(仅对话模型);推理等级来自 Grok CLI 目录(`cli-chat-proxy.grok.com/v1/models`) |
28
32
 
29
33
  只有已登录的 provider 才会出现在会话模型选择器里;登录/退出后列表自动刷新。支持视觉的模型会声明 `['text', 'image']` 输入模态,图片内容会被翻译成各 provider 的 wire 格式。
30
34
 
@@ -73,6 +77,18 @@ cp overlay.example.yml overlay.yml # 然后把 name: 改成本检出的 lib/in
73
77
  dsh --profile headless --patch <检出目录>/overlay.yml "你的任务"
74
78
  ```
75
79
 
80
+ ## 更新
81
+
82
+ npm 安装的:
83
+
84
+ ```sh
85
+ dsh plugin --profile web update --latest dsh-plugin-subscriptions
86
+ ```
87
+
88
+ GitHub 安装的:重新执行一遍 `add github:V1ki/dsh-plugin-subscriptions` —— 会重新拉取源码并构建。link 的本地检出只需在检出目录里 `git pull && pnpm build`。
89
+
90
+ 无论哪种方式,更新后都要重启 `dsh web` 才会加载新版本。
91
+
76
92
  ## 使用
77
93
 
78
94
  1. `dsh web`,打开打印的 URL。
package/lib/index.js CHANGED
@@ -678,14 +678,26 @@ var TokenManager = class {
678
678
  /** How long a discovered catalog is trusted before re-fetching. */
679
679
  const DISCOVERY_TTL_MS = 5 * 6e4;
680
680
  /**
681
- * TTL cache for one provider's discovered model catalog. Only `listModels`
682
- * populates it (via {@link get}); `resolveModel` reads {@link cached} so it
683
- * never performs network I/O. A 401 during a fetch must call
684
- * {@link invalidate}.
681
+ * Cache for one provider's discovered model catalog. The TTL only decides
682
+ * when to REFRESH; it never makes the cache forget: capability metadata
683
+ * (reasoning efforts) must stay stable for a session that selected an effort,
684
+ * or mid-conversation calls fail UNSUPPORTED_REASONING_EFFORT the moment the
685
+ * cache goes stale. `listModels` awaits freshness via {@link get};
686
+ * `resolveModel` uses {@link resolve}, which serves the last-known catalog
687
+ * while a stale entry refreshes in the background, and only awaits the fetch
688
+ * when nothing is known yet. An optional {@link CatalogPersistence} seeds the
689
+ * last-known state across restarts and receives every successful fetch. A 401
690
+ * during a fetch must call {@link invalidate}.
685
691
  */
686
692
  var ModelCatalogCache = class {
687
693
  entry;
688
- constructor(ttlMs = DISCOVERY_TTL_MS) {
694
+ inflight;
695
+ /** Settles once the persisted snapshot (when any) has been considered. */
696
+ seeded;
697
+ /** Set by {@link invalidate} so an in-flight disk read cannot resurrect dropped state. */
698
+ seedDisabled = false;
699
+ constructor(persistence, ttlMs = DISCOVERY_TTL_MS) {
700
+ this.persistence = persistence;
689
701
  this.ttlMs = ttlMs;
690
702
  }
691
703
  /**
@@ -696,27 +708,200 @@ var ModelCatalogCache = class {
696
708
  if (this.entry === void 0 || Date.now() - this.entry.at >= this.ttlMs) return void 0;
697
709
  return this.entry.models;
698
710
  }
711
+ /** Load the persisted snapshot once; a fetch or invalidate that landed first wins. */
712
+ ensureSeeded() {
713
+ if (this.persistence === void 0) return Promise.resolve();
714
+ this.seeded ??= this.persistence.load().then((snapshot) => {
715
+ if (snapshot !== void 0 && this.entry === void 0 && !this.seedDisabled) this.entry = snapshot;
716
+ }, () => void 0);
717
+ return this.seeded;
718
+ }
719
+ /** Run (or join) the single in-flight fetch, updating memory and disk on success. */
720
+ refresh(fetcher) {
721
+ this.inflight ??= fetcher().then((models) => {
722
+ const snapshot = {
723
+ at: Date.now(),
724
+ models
725
+ };
726
+ this.entry = snapshot;
727
+ this.persistence?.save(snapshot).catch(() => void 0);
728
+ return models;
729
+ }).finally(() => {
730
+ this.inflight = void 0;
731
+ });
732
+ return this.inflight;
733
+ }
699
734
  /**
700
735
  * Return the cached catalog when fresh, otherwise fetch and cache it.
701
736
  * @param fetcher - performs the provider's model-list request.
702
737
  * @returns the discovered models.
738
+ * @throws the fetcher's failure (the `listModels` caller warns and falls back).
703
739
  */
704
740
  async get(fetcher) {
705
- const cached = this.cached();
706
- if (cached !== void 0) return cached;
707
- const models = await fetcher();
708
- this.entry = {
709
- at: Date.now(),
710
- models
711
- };
712
- return models;
741
+ await this.ensureSeeded();
742
+ return this.cached() ?? this.refresh(fetcher);
743
+ }
744
+ /**
745
+ * The models for capability resolution. A fresh cache answers directly; a
746
+ * stale one answers immediately from the last-known catalog while a
747
+ * background refresh runs (a mid-conversation `resolveModel` must neither
748
+ * block on nor fail with the network); a cold cache awaits one fetch.
749
+ * @param fetcher - performs the provider's model-list request.
750
+ * @returns the models, or `undefined` when nothing is known (the caller
751
+ * falls back to its static metadata). Never throws.
752
+ */
753
+ async resolve(fetcher) {
754
+ await this.ensureSeeded();
755
+ const fresh = this.cached();
756
+ if (fresh !== void 0) return fresh;
757
+ const known = this.entry?.models;
758
+ if (known !== void 0) {
759
+ this.refresh(fetcher).catch(() => void 0);
760
+ return known;
761
+ }
762
+ try {
763
+ return await this.refresh(fetcher);
764
+ } catch {
765
+ return;
766
+ }
713
767
  }
714
768
  /** Drop the cached catalog (e.g. after a 401 proved the credential changed). */
715
769
  invalidate() {
716
770
  this.entry = void 0;
771
+ this.seedDisabled = true;
772
+ this.persistence?.clear().catch(() => void 0);
717
773
  }
718
774
  };
719
775
 
776
+ //#endregion
777
+ //#region src/providers/catalog-store.ts
778
+ /**
779
+ * Absolute path of the catalog store file.
780
+ * @returns `dshHomePath('plugins', 'subscriptions', 'models.json')`.
781
+ */
782
+ function modelsFilePath() {
783
+ return dshHomePath("plugins", "subscriptions", "models.json");
784
+ }
785
+ /** Validate one persisted reasoning block, or undefined when malformed. */
786
+ function sanitizeReasoning(value) {
787
+ if (typeof value !== "object" || value === null) return void 0;
788
+ const raw = value;
789
+ if (!Array.isArray(raw.efforts) || raw.efforts.length === 0) return void 0;
790
+ const seen = /* @__PURE__ */ new Set();
791
+ const efforts = [];
792
+ for (const entry of raw.efforts) {
793
+ if (typeof entry !== "object" || entry === null) return void 0;
794
+ const effort = entry;
795
+ if (typeof effort.id !== "string" || effort.id.length === 0 || typeof effort.name !== "string" || effort.name.length === 0 || effort.description !== void 0 && typeof effort.description !== "string" || seen.has(effort.id)) return void 0;
796
+ seen.add(effort.id);
797
+ efforts.push({
798
+ id: ReasoningEffortId(effort.id),
799
+ name: effort.name,
800
+ ...effort.description === void 0 ? {} : { description: effort.description }
801
+ });
802
+ }
803
+ if (raw.defaultEffort !== void 0 && (typeof raw.defaultEffort !== "string" || !seen.has(raw.defaultEffort))) return void 0;
804
+ return {
805
+ efforts,
806
+ ...raw.defaultEffort === void 0 ? {} : { defaultEffort: ReasoningEffortId(raw.defaultEffort) }
807
+ };
808
+ }
809
+ /** Validate one persisted model, or undefined when malformed. */
810
+ function sanitizeModel(value) {
811
+ if (typeof value !== "object" || value === null) return void 0;
812
+ const raw = value;
813
+ if (typeof raw.id !== "string" || raw.id.length === 0 || typeof raw.name !== "string" || raw.name.length === 0 || raw.description !== void 0 && typeof raw.description !== "string" || raw.contextWindow !== void 0 && (typeof raw.contextWindow !== "number" || !Number.isInteger(raw.contextWindow) || raw.contextWindow <= 0) || raw.priority !== void 0 && (typeof raw.priority !== "number" || !Number.isFinite(raw.priority))) return void 0;
814
+ const reasoning = raw.reasoning === void 0 ? void 0 : sanitizeReasoning(raw.reasoning);
815
+ if (raw.reasoning !== void 0 && reasoning === void 0) return void 0;
816
+ return {
817
+ id: raw.id,
818
+ name: raw.name,
819
+ ...raw.description === void 0 ? {} : { description: raw.description },
820
+ ...raw.contextWindow === void 0 ? {} : { contextWindow: raw.contextWindow },
821
+ ...raw.priority === void 0 ? {} : { priority: raw.priority },
822
+ ...reasoning === void 0 ? {} : { reasoning }
823
+ };
824
+ }
825
+ /**
826
+ * Validate one persisted snapshot. Strict: any malformed field drops the
827
+ * whole snapshot rather than repairing it — the next successful discovery
828
+ * rewrites the entry anyway.
829
+ * @param value - the raw per-provider file entry.
830
+ * @returns the validated snapshot, or undefined when unusable.
831
+ */
832
+ function sanitizeSnapshot(value) {
833
+ if (typeof value !== "object" || value === null) return void 0;
834
+ const raw = value;
835
+ if (typeof raw.at !== "number" || !Number.isFinite(raw.at)) return void 0;
836
+ if (!Array.isArray(raw.models) || raw.models.length === 0) return void 0;
837
+ const seen = /* @__PURE__ */ new Set();
838
+ const models = [];
839
+ for (const entry of raw.models) {
840
+ const model = sanitizeModel(entry);
841
+ if (model === void 0 || seen.has(model.id)) return void 0;
842
+ seen.add(model.id);
843
+ models.push(model);
844
+ }
845
+ return {
846
+ at: raw.at,
847
+ models
848
+ };
849
+ }
850
+ /** Read the whole file; missing or unparsable reads as an empty cache. */
851
+ async function readCatalogFile(path) {
852
+ let text;
853
+ try {
854
+ text = await readFile(path, "utf8");
855
+ } catch {
856
+ return {};
857
+ }
858
+ try {
859
+ const parsed = JSON.parse(text);
860
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
861
+ return parsed;
862
+ } catch {
863
+ return {};
864
+ }
865
+ }
866
+ /** Persist the whole file atomically (tmp file + rename). */
867
+ async function writeCatalogFile(store, path) {
868
+ await mkdir(dirname(path), { recursive: true });
869
+ const tmp = `${path}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
870
+ try {
871
+ await writeFile(tmp, JSON.stringify(store, null, 2));
872
+ await rename(tmp, path);
873
+ } catch (error) {
874
+ await rm(tmp, { force: true });
875
+ throw error;
876
+ }
877
+ }
878
+ /**
879
+ * Build the durable half of one provider's catalog cache over the shared
880
+ * models.json file (concurrent writers are last-writer-wins, acceptable for
881
+ * a cache).
882
+ * @param provider - the provider route keying the file entry.
883
+ * @param path - store file path; defaults to {@link modelsFilePath}.
884
+ * @returns the persistence hooks for {@link ModelCatalogCache}.
885
+ */
886
+ function catalogStore(provider, path = modelsFilePath()) {
887
+ return {
888
+ async load() {
889
+ return sanitizeSnapshot((await readCatalogFile(path))[provider]);
890
+ },
891
+ async save(snapshot) {
892
+ const store = await readCatalogFile(path);
893
+ store[provider] = snapshot;
894
+ await writeCatalogFile(store, path);
895
+ },
896
+ async clear() {
897
+ const store = await readCatalogFile(path);
898
+ if (store[provider] === void 0) return;
899
+ delete store[provider];
900
+ await writeCatalogFile(store, path);
901
+ }
902
+ };
903
+ }
904
+
720
905
  //#endregion
721
906
  //#region src/auth/jwt.ts
722
907
  /** Minimal JWT payload decoding for claims extraction (no signature verification). */
@@ -1430,10 +1615,15 @@ async function fetchCodexModels(session, fetchFn = fetch) {
1430
1615
  }
1431
1616
  /** Codex wire adapter: one instance serves the `codex` provider route. */
1432
1617
  var CodexAdapter = class extends LlmAdapter {
1433
- catalog = new ModelCatalogCache();
1618
+ catalog;
1434
1619
  constructor(options) {
1435
1620
  super();
1436
1621
  this.options = options;
1622
+ this.catalog = new ModelCatalogCache(options.catalogStore);
1623
+ }
1624
+ /** Discovery fetcher: resolves the session through the refresh-aware path. */
1625
+ async fetchCatalog() {
1626
+ return fetchCodexModels(await this.options.tokens.session(), this.options.fetchFn);
1437
1627
  }
1438
1628
  providerInfo(provider) {
1439
1629
  return {
@@ -1453,7 +1643,7 @@ var CodexAdapter = class extends LlmAdapter {
1453
1643
  if (await this.options.tokens.peek() === void 0) return [];
1454
1644
  if (!this.options.discovery) return this.staticModels(provider);
1455
1645
  try {
1456
- return (await this.catalog.get(async () => fetchCodexModels(await this.options.tokens.session(), this.options.fetchFn))).map((model) => ({
1646
+ return (await this.catalog.get(() => this.fetchCatalog())).map((model) => ({
1457
1647
  provider,
1458
1648
  id: model.id,
1459
1649
  name: model.name,
@@ -1467,10 +1657,21 @@ var CodexAdapter = class extends LlmAdapter {
1467
1657
  return this.staticModels(provider);
1468
1658
  }
1469
1659
  }
1470
- resolveModel(provider, model) {
1471
- const discovered = this.options.discovery ? this.catalog.cached()?.find((entry) => entry.id === model) : void 0;
1660
+ /**
1661
+ * The discovered entry for one model. Resolved through the cache's
1662
+ * stale-while-revalidate path so capability metadata stays stable across a
1663
+ * long conversation: a discovered-only effort (one missing from the static
1664
+ * CODEX_EFFORTS list) selected by the user must not vanish — and fail the
1665
+ * call — just because the TTL lapsed mid-turn.
1666
+ */
1667
+ async discovered(model) {
1668
+ if (!this.options.discovery) return void 0;
1669
+ return (await this.catalog.resolve(() => this.fetchCatalog()))?.find((entry) => entry.id === model);
1670
+ }
1671
+ async resolveModel(provider, model) {
1672
+ const discovered = await this.discovered(model);
1472
1673
  const configured = this.options.models.find((entry) => entry.id === model);
1473
- return Promise.resolve({
1674
+ return {
1474
1675
  provider,
1475
1676
  id: model,
1476
1677
  name: discovered?.name ?? configured?.name ?? model,
@@ -1482,7 +1683,7 @@ var CodexAdapter = class extends LlmAdapter {
1482
1683
  efforts: CODEX_EFFORTS,
1483
1684
  defaultEffort: CODEX_DEFAULT_EFFORT
1484
1685
  }
1485
- });
1686
+ };
1486
1687
  }
1487
1688
  async *stream(options) {
1488
1689
  const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
@@ -2439,6 +2640,58 @@ function grokModalities(id) {
2439
2640
  return /code|embed/i.test(id) ? ["text"] : ["text", "image"];
2440
2641
  }
2441
2642
  /**
2643
+ * The Grok Build CLI chat proxy's model catalog — the only grok endpoint that
2644
+ * advertises reasoning capability. The `api.x.ai/v1/models` and
2645
+ * `/v1/language-models` payloads carry pricing, context, and aliases only, so
2646
+ * effort metadata must come from here (the same source the official CLI's
2647
+ * picker uses).
2648
+ */
2649
+ const GROK_CLI_MODELS_URL = "https://cli-chat-proxy.grok.com/v1/models";
2650
+ /** Map one CLI catalog entry's reasoning fields, or undefined when unsupported. */
2651
+ function grokCliReasoning(entry) {
2652
+ if (entry.supports_reasoning_effort !== true) return void 0;
2653
+ const efforts = (entry.reasoning_efforts ?? []).filter((level) => typeof level.value === "string" && level.value.length > 0).map((level) => ({
2654
+ id: ReasoningEffortId(level.value),
2655
+ name: typeof level.label === "string" && level.label.length > 0 ? level.label : level.value,
2656
+ ...typeof level.description === "string" && level.description.length > 0 ? { description: level.description } : {}
2657
+ }));
2658
+ if (efforts.length === 0) return void 0;
2659
+ const defaultEffort = typeof entry.reasoning_effort === "string" && efforts.some((effort) => effort.id === ReasoningEffortId(entry.reasoning_effort)) ? ReasoningEffortId(entry.reasoning_effort) : void 0;
2660
+ return {
2661
+ efforts,
2662
+ ...defaultEffort === void 0 ? {} : { defaultEffort }
2663
+ };
2664
+ }
2665
+ /**
2666
+ * Fetch the CLI catalog and index its per-model metadata by model id.
2667
+ * @param session - the stored session (used as-is; never refreshed here).
2668
+ * @param fetchFn - fetch implementation (injectable for tests).
2669
+ * @returns model id → contributed metadata.
2670
+ */
2671
+ async function fetchGrokCliCatalog(session, fetchFn = fetch) {
2672
+ const response = await fetchFn(GROK_CLI_MODELS_URL, { headers: {
2673
+ "authorization": `Bearer ${session.accessToken}`,
2674
+ "x-xai-token-auth": "xai-grok-cli",
2675
+ "accept": "application/json",
2676
+ ...attributionHeaders()
2677
+ } });
2678
+ if (!response.ok) throw await oauthEndpointError(response, "grok CLI catalog");
2679
+ const payload = await response.json();
2680
+ if (!Array.isArray(payload.data)) throw new Error("grok CLI catalog returned no data array");
2681
+ const catalog = /* @__PURE__ */ new Map();
2682
+ for (const entry of payload.data) {
2683
+ if (typeof entry.id !== "string" || entry.id.length === 0) continue;
2684
+ const reasoning = grokCliReasoning(entry);
2685
+ catalog.set(entry.id, {
2686
+ ...typeof entry.name === "string" && entry.name.length > 0 ? { name: entry.name } : {},
2687
+ ...typeof entry.description === "string" && entry.description.length > 0 ? { description: entry.description } : {},
2688
+ ...typeof entry.context_window === "number" && entry.context_window > 0 ? { contextWindow: entry.context_window } : {},
2689
+ ...reasoning === void 0 ? {} : { reasoning }
2690
+ });
2691
+ }
2692
+ return catalog;
2693
+ }
2694
+ /**
2442
2695
  * The /v1/models list also serves generation models that cannot chat
2443
2696
  * (grok-imagine-image*, grok-imagine-video*) and embedding models; the picker
2444
2697
  * must not offer them. Heuristic over the id substring, verified against the
@@ -2448,17 +2701,24 @@ function isChatModel(id) {
2448
2701
  return !/imagine|image-|video|embed/i.test(id);
2449
2702
  }
2450
2703
  /**
2451
- * Fetch the live grok model list.
2704
+ * Fetch the live grok model list, enriched with the CLI catalog's per-model
2705
+ * metadata (display name, context window, reasoning efforts). The api.x.ai
2706
+ * list stays authoritative for which models exist; the CLI catalog is
2707
+ * enrichment only, so its failure degrades to a plain list instead of taking
2708
+ * discovery down — models it does not cover simply expose no efforts.
2452
2709
  * @param session - the stored session (used as-is; never refreshed here).
2453
2710
  * @param fetchFn - fetch implementation (injectable for tests).
2454
- * @returns discovered chat models in endpoint order (id doubles as the name).
2711
+ * @param onWarn - warning sink for a failed CLI catalog fetch.
2712
+ * @returns discovered chat models in endpoint order.
2455
2713
  */
2456
- async function fetchGrokModels(session, fetchFn = fetch) {
2457
- const response = await fetchFn(GROK_MODELS_URL, { headers: {
2714
+ async function fetchGrokModels(session, fetchFn = fetch, onWarn) {
2715
+ const [response, cliCatalog] = await Promise.all([fetchFn(GROK_MODELS_URL, { headers: {
2458
2716
  "authorization": `Bearer ${session.accessToken}`,
2459
2717
  "accept": "application/json",
2460
2718
  ...attributionHeaders()
2461
- } });
2719
+ } }), fetchGrokCliCatalog(session, fetchFn).catch((error) => {
2720
+ onWarn?.(`grok CLI catalog fetch failed; reasoning efforts are unavailable (${errorChain(error)})`);
2721
+ })]);
2462
2722
  if (!response.ok) throw await oauthEndpointError(response, "grok models");
2463
2723
  const payload = await response.json();
2464
2724
  if (!Array.isArray(payload.data)) throw new Error("grok models endpoint returned no data array");
@@ -2470,7 +2730,8 @@ async function fetchGrokModels(session, fetchFn = fetch) {
2470
2730
  seen.add(entry.id);
2471
2731
  discovered.push({
2472
2732
  id: entry.id,
2473
- name: entry.id
2733
+ name: entry.id,
2734
+ ...cliCatalog?.get(entry.id)
2474
2735
  });
2475
2736
  }
2476
2737
  if (discovered.length === 0) throw new Error("grok models endpoint returned an empty catalog");
@@ -2478,10 +2739,15 @@ async function fetchGrokModels(session, fetchFn = fetch) {
2478
2739
  }
2479
2740
  /** Grok wire adapter: one instance serves the `grok` provider route. */
2480
2741
  var GrokAdapter = class extends LlmAdapter {
2481
- catalog = new ModelCatalogCache();
2742
+ catalog;
2482
2743
  constructor(options) {
2483
2744
  super();
2484
2745
  this.options = options;
2746
+ this.catalog = new ModelCatalogCache(options.catalogStore);
2747
+ }
2748
+ /** Discovery fetcher: resolves the session through the refresh-aware path. */
2749
+ async fetchCatalog() {
2750
+ return fetchGrokModels(await this.options.tokens.session(), this.options.fetchFn, this.options.onWarn);
2485
2751
  }
2486
2752
  providerInfo(provider) {
2487
2753
  return {
@@ -2501,10 +2767,11 @@ var GrokAdapter = class extends LlmAdapter {
2501
2767
  if (await this.options.tokens.peek() === void 0) return [];
2502
2768
  if (!this.options.discovery) return this.staticModels(provider);
2503
2769
  try {
2504
- return (await this.catalog.get(async () => fetchGrokModels(await this.options.tokens.session(), this.options.fetchFn))).map((model) => ({
2770
+ return (await this.catalog.get(() => this.fetchCatalog())).map((model) => ({
2505
2771
  provider,
2506
2772
  id: model.id,
2507
2773
  name: model.name,
2774
+ ...model.description === void 0 ? {} : { description: model.description },
2508
2775
  inputModalities: grokModalities(model.id)
2509
2776
  }));
2510
2777
  } catch (error) {
@@ -2514,17 +2781,31 @@ var GrokAdapter = class extends LlmAdapter {
2514
2781
  return this.staticModels(provider);
2515
2782
  }
2516
2783
  }
2517
- resolveModel(provider, model) {
2518
- const discovered = this.options.discovery ? this.catalog.cached()?.find((entry) => entry.id === model) : void 0;
2784
+ /**
2785
+ * The discovered entry for one model. Resolved through the cache's
2786
+ * stale-while-revalidate path: capability metadata must stay stable across
2787
+ * a long conversation — a session that selected a reasoning effort calls
2788
+ * this on EVERY step, and forgetting the efforts just because the TTL
2789
+ * lapsed mid-turn would fail the call with UNSUPPORTED_REASONING_EFFORT
2790
+ * before provider I/O.
2791
+ */
2792
+ async discovered(model) {
2793
+ if (!this.options.discovery) return void 0;
2794
+ return (await this.catalog.resolve(() => this.fetchCatalog()))?.find((entry) => entry.id === model);
2795
+ }
2796
+ async resolveModel(provider, model) {
2797
+ const discovered = await this.discovered(model);
2519
2798
  const configured = this.options.models.find((entry) => entry.id === model);
2520
- return Promise.resolve({
2799
+ return {
2521
2800
  provider,
2522
2801
  id: model,
2523
2802
  name: discovered?.name ?? configured?.name ?? model,
2803
+ ...discovered?.description === void 0 ? {} : { description: discovered.description },
2524
2804
  inputModalities: configured?.inputModalities ?? grokModalities(model),
2525
- context: { contextWindow: configured?.contextWindow ?? GROK_CONTEXT_WINDOW },
2526
- defaultMaxTokens: configured?.maxTokens ?? GROK_DEFAULT_MAX_TOKENS
2527
- });
2805
+ context: { contextWindow: discovered?.contextWindow ?? configured?.contextWindow ?? GROK_CONTEXT_WINDOW },
2806
+ defaultMaxTokens: configured?.maxTokens ?? GROK_DEFAULT_MAX_TOKENS,
2807
+ ...discovered?.reasoning === void 0 ? {} : { reasoning: discovered.reasoning }
2808
+ };
2528
2809
  }
2529
2810
  async *stream(options) {
2530
2811
  const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
@@ -2556,6 +2837,7 @@ var GrokAdapter = class extends LlmAdapter {
2556
2837
  tool_choice: "auto",
2557
2838
  parallel_tool_calls: true,
2558
2839
  ...options.maxTokens !== void 0 ? { max_output_tokens: options.maxTokens } : {},
2840
+ ...options.reasoningEffort !== void 0 ? { reasoning: { effort: String(options.reasoningEffort) } } : {},
2559
2841
  store: false,
2560
2842
  stream: true
2561
2843
  };
@@ -3239,7 +3521,8 @@ function apply(ctx, config) {
3239
3521
  tokens,
3240
3522
  discovery: !overridden.has("codex"),
3241
3523
  onWarn,
3242
- resolveAttachments
3524
+ resolveAttachments,
3525
+ catalogStore: catalogStore("codex")
3243
3526
  })));
3244
3527
  break;
3245
3528
  }
@@ -3286,7 +3569,8 @@ function apply(ctx, config) {
3286
3569
  tokens,
3287
3570
  discovery: !overridden.has("grok"),
3288
3571
  onWarn,
3289
- resolveAttachments
3572
+ resolveAttachments,
3573
+ catalogStore: catalogStore("grok")
3290
3574
  })));
3291
3575
  break;
3292
3576
  }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * On-disk discovered-model-catalog cache at
3
+ * `~/.dsh/plugins/subscriptions/models.json` — the durable half of each
4
+ * provider's {@link ModelCatalogCache}. One entry per provider: the last
5
+ * successfully discovered catalog with its fetch time, so capability metadata
6
+ * (reasoning efforts) survives restarts and network failures.
7
+ *
8
+ * Unlike the auth store, this file is a cache: a missing, corrupt, or
9
+ * malformed file silently reads as absent, because the next successful
10
+ * discovery rewrites it. Loads are strictly validated — a malformed entry
11
+ * passed through `resolveModel` would make the harness's metadata validation
12
+ * throw on every call, which is worse than having no fallback at all.
13
+ */
14
+ import type { ProviderId } from '../auth/store.js';
15
+ import type { CatalogPersistence, CatalogSnapshot } from './common.js';
16
+ /**
17
+ * Absolute path of the catalog store file.
18
+ * @returns `dshHomePath('plugins', 'subscriptions', 'models.json')`.
19
+ */
20
+ export declare function modelsFilePath(): string;
21
+ /**
22
+ * Validate one persisted snapshot. Strict: any malformed field drops the
23
+ * whole snapshot rather than repairing it — the next successful discovery
24
+ * rewrites the entry anyway.
25
+ * @param value - the raw per-provider file entry.
26
+ * @returns the validated snapshot, or undefined when unusable.
27
+ */
28
+ export declare function sanitizeSnapshot(value: unknown): CatalogSnapshot | undefined;
29
+ /**
30
+ * Build the durable half of one provider's catalog cache over the shared
31
+ * models.json file (concurrent writers are last-writer-wins, acceptable for
32
+ * a cache).
33
+ * @param provider - the provider route keying the file entry.
34
+ * @param path - store file path; defaults to {@link modelsFilePath}.
35
+ * @returns the persistence hooks for {@link ModelCatalogCache}.
36
+ */
37
+ export declare function catalogStore(provider: ProviderId, path?: string): CatalogPersistence;