dsh-plugin-subscriptions 0.3.0 → 0.4.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.
@@ -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;
@@ -0,0 +1,167 @@
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 { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
15
+ import { dirname } from 'node:path';
16
+ import { dshHomePath } from '@deepseek-ai/dsh-home-paths';
17
+ import { ReasoningEffortId } from '@deepseek-ai/dsh-llm';
18
+ /**
19
+ * Absolute path of the catalog store file.
20
+ * @returns `dshHomePath('plugins', 'subscriptions', 'models.json')`.
21
+ */
22
+ export function modelsFilePath() {
23
+ return dshHomePath('plugins', 'subscriptions', 'models.json');
24
+ }
25
+ /** Validate one persisted reasoning block, or undefined when malformed. */
26
+ function sanitizeReasoning(value) {
27
+ if (typeof value !== 'object' || value === null)
28
+ return undefined;
29
+ const raw = value;
30
+ if (!Array.isArray(raw.efforts) || raw.efforts.length === 0)
31
+ return undefined;
32
+ const seen = new Set();
33
+ const efforts = [];
34
+ for (const entry of raw.efforts) {
35
+ if (typeof entry !== 'object' || entry === null)
36
+ return undefined;
37
+ const effort = entry;
38
+ if (typeof effort.id !== 'string' || effort.id.length === 0
39
+ || typeof effort.name !== 'string' || effort.name.length === 0
40
+ || (effort.description !== undefined && typeof effort.description !== 'string')
41
+ || seen.has(effort.id))
42
+ return undefined;
43
+ seen.add(effort.id);
44
+ efforts.push({
45
+ id: ReasoningEffortId(effort.id),
46
+ name: effort.name,
47
+ ...effort.description === undefined ? {} : { description: effort.description },
48
+ });
49
+ }
50
+ if (raw.defaultEffort !== undefined
51
+ && (typeof raw.defaultEffort !== 'string' || !seen.has(raw.defaultEffort)))
52
+ return undefined;
53
+ return {
54
+ efforts,
55
+ ...raw.defaultEffort === undefined ? {} : { defaultEffort: ReasoningEffortId(raw.defaultEffort) },
56
+ };
57
+ }
58
+ /** Validate one persisted model, or undefined when malformed. */
59
+ function sanitizeModel(value) {
60
+ if (typeof value !== 'object' || value === null)
61
+ return undefined;
62
+ const raw = value;
63
+ if (typeof raw.id !== 'string' || raw.id.length === 0
64
+ || typeof raw.name !== 'string' || raw.name.length === 0
65
+ || (raw.description !== undefined && typeof raw.description !== 'string')
66
+ || (raw.contextWindow !== undefined
67
+ && (typeof raw.contextWindow !== 'number' || !Number.isInteger(raw.contextWindow) || raw.contextWindow <= 0))
68
+ || (raw.priority !== undefined
69
+ && (typeof raw.priority !== 'number' || !Number.isFinite(raw.priority))))
70
+ return undefined;
71
+ const reasoning = raw.reasoning === undefined ? undefined : sanitizeReasoning(raw.reasoning);
72
+ if (raw.reasoning !== undefined && reasoning === undefined)
73
+ return undefined;
74
+ return {
75
+ id: raw.id,
76
+ name: raw.name,
77
+ ...raw.description === undefined ? {} : { description: raw.description },
78
+ ...raw.contextWindow === undefined ? {} : { contextWindow: raw.contextWindow },
79
+ ...raw.priority === undefined ? {} : { priority: raw.priority },
80
+ ...reasoning === undefined ? {} : { reasoning },
81
+ };
82
+ }
83
+ /**
84
+ * Validate one persisted snapshot. Strict: any malformed field drops the
85
+ * whole snapshot rather than repairing it — the next successful discovery
86
+ * rewrites the entry anyway.
87
+ * @param value - the raw per-provider file entry.
88
+ * @returns the validated snapshot, or undefined when unusable.
89
+ */
90
+ export function sanitizeSnapshot(value) {
91
+ if (typeof value !== 'object' || value === null)
92
+ return undefined;
93
+ const raw = value;
94
+ if (typeof raw.at !== 'number' || !Number.isFinite(raw.at))
95
+ return undefined;
96
+ if (!Array.isArray(raw.models) || raw.models.length === 0)
97
+ return undefined;
98
+ const seen = new Set();
99
+ const models = [];
100
+ for (const entry of raw.models) {
101
+ const model = sanitizeModel(entry);
102
+ if (model === undefined || seen.has(model.id))
103
+ return undefined;
104
+ seen.add(model.id);
105
+ models.push(model);
106
+ }
107
+ return { at: raw.at, models };
108
+ }
109
+ /** Read the whole file; missing or unparsable reads as an empty cache. */
110
+ async function readCatalogFile(path) {
111
+ let text;
112
+ try {
113
+ text = await readFile(path, 'utf8');
114
+ }
115
+ catch {
116
+ return {};
117
+ }
118
+ try {
119
+ const parsed = JSON.parse(text);
120
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
121
+ return {};
122
+ return parsed;
123
+ }
124
+ catch {
125
+ return {};
126
+ }
127
+ }
128
+ /** Persist the whole file atomically (tmp file + rename). */
129
+ async function writeCatalogFile(store, path) {
130
+ await mkdir(dirname(path), { recursive: true });
131
+ const tmp = `${path}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
132
+ try {
133
+ await writeFile(tmp, JSON.stringify(store, null, 2));
134
+ await rename(tmp, path);
135
+ }
136
+ catch (error) {
137
+ await rm(tmp, { force: true });
138
+ throw error;
139
+ }
140
+ }
141
+ /**
142
+ * Build the durable half of one provider's catalog cache over the shared
143
+ * models.json file (concurrent writers are last-writer-wins, acceptable for
144
+ * a cache).
145
+ * @param provider - the provider route keying the file entry.
146
+ * @param path - store file path; defaults to {@link modelsFilePath}.
147
+ * @returns the persistence hooks for {@link ModelCatalogCache}.
148
+ */
149
+ export function catalogStore(provider, path = modelsFilePath()) {
150
+ return {
151
+ async load() {
152
+ return sanitizeSnapshot((await readCatalogFile(path))[provider]);
153
+ },
154
+ async save(snapshot) {
155
+ const store = await readCatalogFile(path);
156
+ store[provider] = snapshot;
157
+ await writeCatalogFile(store, path);
158
+ },
159
+ async clear() {
160
+ const store = await readCatalogFile(path);
161
+ if (store[provider] === undefined)
162
+ return;
163
+ delete store[provider];
164
+ await writeCatalogFile(store, path);
165
+ },
166
+ };
167
+ }
@@ -9,7 +9,7 @@ import type { FlowSpec } from '../auth/oauth-flow.js';
9
9
  import type { CodexSession } from '../auth/store.js';
10
10
  import type { AttachmentStore } from '@deepseek-ai/dsh-attachment';
11
11
  import { TokenManager } from './common.js';
12
- import type { DiscoveredModel, FetchFn, ModelEntry, ProviderUsage } from './common.js';
12
+ import type { CatalogPersistence, DiscoveredModel, FetchFn, ModelEntry, ProviderUsage } from './common.js';
13
13
  export declare const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
14
14
  export declare const CODEX_AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
15
15
  export declare const CODEX_TOKEN_URL = "https://auth.openai.com/oauth/token";
@@ -57,8 +57,11 @@ export declare const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usa
57
57
  /**
58
58
  * Fetch the codex subscription usage from the ChatGPT backend wham/usage
59
59
  * endpoint (the source of the codex CLI `/status` rate-limit lines). The
60
- * primary window is the rolling session (5-hour) lane, the secondary window
61
- * the weekly lane; the lookup itself consumes no rate-limit budget.
60
+ * windows are classified by their reported duration (`limit_window_seconds`)
61
+ * rather than by slot, since the backend has been observed to report the
62
+ * weekly lane as `primary_window` without a secondary window; slot order is
63
+ * kept only as a fallback when the duration is absent. The lookup itself
64
+ * consumes no rate-limit budget.
62
65
  * @param session - the stored session (used as-is; never refreshed here).
63
66
  * @param fetchFn - fetch implementation (injectable for tests).
64
67
  * @param signal - caller cancellation from the RPC transport.
@@ -93,15 +96,27 @@ export interface CodexAdapterOptions {
93
96
  fetchFn?: FetchFn;
94
97
  /** Resolve the attachment service per request; absent means image requests fail loudly. */
95
98
  resolveAttachments?: () => AttachmentStore | undefined;
99
+ /** Durable catalog store seeding capability metadata across restarts. */
100
+ catalogStore?: CatalogPersistence;
96
101
  }
97
102
  /** Codex wire adapter: one instance serves the `codex` provider route. */
98
103
  export declare class CodexAdapter extends LlmAdapter {
99
104
  private readonly options;
100
105
  private readonly catalog;
101
106
  constructor(options: CodexAdapterOptions);
107
+ /** Discovery fetcher: resolves the session through the refresh-aware path. */
108
+ private fetchCatalog;
102
109
  providerInfo(provider: string): LlmProviderInfo;
103
110
  private staticModels;
104
111
  listModels(provider: string): Promise<readonly LlmModelInfo[]>;
112
+ /**
113
+ * The discovered entry for one model. Resolved through the cache's
114
+ * stale-while-revalidate path so capability metadata stays stable across a
115
+ * long conversation: a discovered-only effort (one missing from the static
116
+ * CODEX_EFFORTS list) selected by the user must not vanish — and fail the
117
+ * call — just because the TTL lapsed mid-turn.
118
+ */
119
+ private discovered;
105
120
  resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo>;
106
121
  stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
107
122
  private request;
@@ -189,8 +189,31 @@ export function isCodexPermanentRefreshError(error) {
189
189
  && PERMANENT_REFRESH_CODES.has(error.oauthCode);
190
190
  }
191
191
  export const CODEX_USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage';
192
+ /** Seconds of the canonical 5-hour session and 7-day weekly windows. */
193
+ const SESSION_WINDOW_SECONDS = 5 * 60 * 60;
194
+ const WEEKLY_WINDOW_SECONDS = 7 * 24 * 60 * 60;
195
+ /** Whether a reported duration approximately matches the expected window length. */
196
+ function matchesWindow(seconds, expected) {
197
+ return seconds >= expected * 0.95 && seconds <= expected * 1.05;
198
+ }
199
+ /**
200
+ * Classify a wham/usage window by its reported duration. The backend has been
201
+ * observed to place the weekly lane in `primary_window` with no secondary
202
+ * window, so slot position alone is unreliable; the caller's positional
203
+ * fallback applies only when the duration is absent.
204
+ */
205
+ function codexWindowKind(window, fallback) {
206
+ const seconds = window.limit_window_seconds;
207
+ if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds <= 0)
208
+ return fallback;
209
+ if (matchesWindow(seconds, SESSION_WINDOW_SECONDS))
210
+ return 'session';
211
+ if (matchesWindow(seconds, WEEKLY_WINDOW_SECONDS))
212
+ return 'weekly';
213
+ return 'other';
214
+ }
192
215
  /** Map one wham/usage window into a {@link UsageWindow}; undefined when unusable. */
193
- function codexUsageWindow(value, kind) {
216
+ function codexUsageWindow(value, fallbackKind) {
194
217
  if (typeof value !== 'object' || value === null)
195
218
  return undefined;
196
219
  const window = value;
@@ -203,13 +226,20 @@ function codexUsageWindow(value, kind) {
203
226
  else if (typeof window.reset_after_seconds === 'number' && window.reset_after_seconds > 0) {
204
227
  resetsAt = Date.now() + window.reset_after_seconds * 1000;
205
228
  }
206
- return { kind, usedPercent: window.used_percent, ...resetsAt === undefined ? {} : { resetsAt } };
229
+ return {
230
+ kind: codexWindowKind(window, fallbackKind),
231
+ usedPercent: window.used_percent,
232
+ ...resetsAt === undefined ? {} : { resetsAt },
233
+ };
207
234
  }
208
235
  /**
209
236
  * Fetch the codex subscription usage from the ChatGPT backend wham/usage
210
237
  * endpoint (the source of the codex CLI `/status` rate-limit lines). The
211
- * primary window is the rolling session (5-hour) lane, the secondary window
212
- * the weekly lane; the lookup itself consumes no rate-limit budget.
238
+ * windows are classified by their reported duration (`limit_window_seconds`)
239
+ * rather than by slot, since the backend has been observed to report the
240
+ * weekly lane as `primary_window` without a secondary window; slot order is
241
+ * kept only as a fallback when the duration is absent. The lookup itself
242
+ * consumes no rate-limit budget.
213
243
  * @param session - the stored session (used as-is; never refreshed here).
214
244
  * @param fetchFn - fetch implementation (injectable for tests).
215
245
  * @param signal - caller cancellation from the RPC transport.
@@ -327,10 +357,15 @@ export async function fetchCodexModels(session, fetchFn = fetch) {
327
357
  /** Codex wire adapter: one instance serves the `codex` provider route. */
328
358
  export class CodexAdapter extends LlmAdapter {
329
359
  options;
330
- catalog = new ModelCatalogCache();
360
+ catalog;
331
361
  constructor(options) {
332
362
  super();
333
363
  this.options = options;
364
+ this.catalog = new ModelCatalogCache(options.catalogStore);
365
+ }
366
+ /** Discovery fetcher: resolves the session through the refresh-aware path. */
367
+ async fetchCatalog() {
368
+ return fetchCodexModels(await this.options.tokens.session(), this.options.fetchFn);
334
369
  }
335
370
  providerInfo(provider) {
336
371
  return { id: provider, name: 'ChatGPT (Codex)' };
@@ -354,7 +389,7 @@ export class CodexAdapter extends LlmAdapter {
354
389
  // The fetcher runs only on a cache miss, and resolves the session
355
390
  // through the refresh-aware path so an expired access token renews here
356
391
  // instead of failing discovery into the static fallback.
357
- const discovered = await this.catalog.get(async () => fetchCodexModels(await this.options.tokens.session(), this.options.fetchFn));
392
+ const discovered = await this.catalog.get(() => this.fetchCatalog());
358
393
  return discovered.map(model => ({
359
394
  provider,
360
395
  id: model.id,
@@ -375,14 +410,25 @@ export class CodexAdapter extends LlmAdapter {
375
410
  return this.staticModels(provider);
376
411
  }
377
412
  }
378
- resolveModel(provider, model) {
379
- // Discovered metadata (when discovery is on and the cache is warm) wins
380
- // over the static entry; the static entry wins over the built-in defaults.
381
- const discovered = this.options.discovery
382
- ? this.catalog.cached()?.find(entry => entry.id === model)
383
- : undefined;
413
+ /**
414
+ * The discovered entry for one model. Resolved through the cache's
415
+ * stale-while-revalidate path so capability metadata stays stable across a
416
+ * long conversation: a discovered-only effort (one missing from the static
417
+ * CODEX_EFFORTS list) selected by the user must not vanish — and fail the
418
+ * call — just because the TTL lapsed mid-turn.
419
+ */
420
+ async discovered(model) {
421
+ if (!this.options.discovery)
422
+ return undefined;
423
+ const models = await this.catalog.resolve(() => this.fetchCatalog());
424
+ return models?.find(entry => entry.id === model);
425
+ }
426
+ async resolveModel(provider, model) {
427
+ // Discovered metadata (when discovery is on) wins over the static entry;
428
+ // the static entry wins over the built-in defaults.
429
+ const discovered = await this.discovered(model);
384
430
  const configured = this.options.models.find(entry => entry.id === model);
385
- return Promise.resolve({
431
+ return {
386
432
  provider,
387
433
  id: model,
388
434
  name: discovered?.name ?? configured?.name ?? model,
@@ -391,7 +437,7 @@ export class CodexAdapter extends LlmAdapter {
391
437
  context: { contextWindow: discovered?.contextWindow ?? configured?.contextWindow ?? CODEX_CONTEXT_WINDOW },
392
438
  defaultMaxTokens: configured?.maxTokens ?? CODEX_DEFAULT_MAX_TOKENS,
393
439
  reasoning: discovered?.reasoning ?? { efforts: CODEX_EFFORTS, defaultEffort: CODEX_DEFAULT_EFFORT },
394
- });
440
+ };
395
441
  }
396
442
  async *stream(options) {
397
443
  const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
@@ -178,27 +178,69 @@ export interface DiscoveredModel {
178
178
  }
179
179
  /** How long a discovered catalog is trusted before re-fetching. */
180
180
  export declare const DISCOVERY_TTL_MS: number;
181
+ /** A durable snapshot of one provider's discovered catalog. */
182
+ export interface CatalogSnapshot {
183
+ /** Epoch milliseconds of the successful fetch that produced it. */
184
+ at: number;
185
+ models: DiscoveredModel[];
186
+ }
187
+ /** The durable half of a {@link ModelCatalogCache} (the models.json store). */
188
+ export interface CatalogPersistence {
189
+ /** The last persisted snapshot, or undefined when absent or unusable. */
190
+ load(): Promise<CatalogSnapshot | undefined>;
191
+ /** Persist a fresh snapshot (write-through after every successful fetch). */
192
+ save(snapshot: CatalogSnapshot): Promise<void>;
193
+ /** Drop the persisted snapshot (a 401 proved the credential changed). */
194
+ clear(): Promise<void>;
195
+ }
181
196
  /**
182
- * TTL cache for one provider's discovered model catalog. Only `listModels`
183
- * populates it (via {@link get}); `resolveModel` reads {@link cached} so it
184
- * never performs network I/O. A 401 during a fetch must call
185
- * {@link invalidate}.
197
+ * Cache for one provider's discovered model catalog. The TTL only decides
198
+ * when to REFRESH; it never makes the cache forget: capability metadata
199
+ * (reasoning efforts) must stay stable for a session that selected an effort,
200
+ * or mid-conversation calls fail UNSUPPORTED_REASONING_EFFORT the moment the
201
+ * cache goes stale. `listModels` awaits freshness via {@link get};
202
+ * `resolveModel` uses {@link resolve}, which serves the last-known catalog
203
+ * while a stale entry refreshes in the background, and only awaits the fetch
204
+ * when nothing is known yet. An optional {@link CatalogPersistence} seeds the
205
+ * last-known state across restarts and receives every successful fetch. A 401
206
+ * during a fetch must call {@link invalidate}.
186
207
  */
187
208
  export declare class ModelCatalogCache {
209
+ private readonly persistence?;
188
210
  private readonly ttlMs;
189
211
  private entry;
190
- constructor(ttlMs?: number);
212
+ private inflight;
213
+ /** Settles once the persisted snapshot (when any) has been considered. */
214
+ private seeded;
215
+ /** Set by {@link invalidate} so an in-flight disk read cannot resurrect dropped state. */
216
+ private seedDisabled;
217
+ constructor(persistence?: CatalogPersistence | undefined, ttlMs?: number);
191
218
  /**
192
219
  * The cached catalog when fresh, without fetching.
193
220
  * @returns the cached models, or `undefined` when absent or stale.
194
221
  */
195
222
  cached(): readonly DiscoveredModel[] | undefined;
223
+ /** Load the persisted snapshot once; a fetch or invalidate that landed first wins. */
224
+ private ensureSeeded;
225
+ /** Run (or join) the single in-flight fetch, updating memory and disk on success. */
226
+ private refresh;
196
227
  /**
197
228
  * Return the cached catalog when fresh, otherwise fetch and cache it.
198
229
  * @param fetcher - performs the provider's model-list request.
199
230
  * @returns the discovered models.
231
+ * @throws the fetcher's failure (the `listModels` caller warns and falls back).
200
232
  */
201
233
  get(fetcher: () => Promise<DiscoveredModel[]>): Promise<readonly DiscoveredModel[]>;
234
+ /**
235
+ * The models for capability resolution. A fresh cache answers directly; a
236
+ * stale one answers immediately from the last-known catalog while a
237
+ * background refresh runs (a mid-conversation `resolveModel` must neither
238
+ * block on nor fail with the network); a cold cache awaits one fetch.
239
+ * @param fetcher - performs the provider's model-list request.
240
+ * @returns the models, or `undefined` when nothing is known (the caller
241
+ * falls back to its static metadata). Never throws.
242
+ */
243
+ resolve(fetcher: () => Promise<DiscoveredModel[]>): Promise<readonly DiscoveredModel[] | undefined>;
202
244
  /** Drop the cached catalog (e.g. after a 401 proved the credential changed). */
203
245
  invalidate(): void;
204
246
  }
@@ -262,15 +262,28 @@ export class TokenManager {
262
262
  /** How long a discovered catalog is trusted before re-fetching. */
263
263
  export const DISCOVERY_TTL_MS = 5 * 60_000;
264
264
  /**
265
- * TTL cache for one provider's discovered model catalog. Only `listModels`
266
- * populates it (via {@link get}); `resolveModel` reads {@link cached} so it
267
- * never performs network I/O. A 401 during a fetch must call
268
- * {@link invalidate}.
265
+ * Cache for one provider's discovered model catalog. The TTL only decides
266
+ * when to REFRESH; it never makes the cache forget: capability metadata
267
+ * (reasoning efforts) must stay stable for a session that selected an effort,
268
+ * or mid-conversation calls fail UNSUPPORTED_REASONING_EFFORT the moment the
269
+ * cache goes stale. `listModels` awaits freshness via {@link get};
270
+ * `resolveModel` uses {@link resolve}, which serves the last-known catalog
271
+ * while a stale entry refreshes in the background, and only awaits the fetch
272
+ * when nothing is known yet. An optional {@link CatalogPersistence} seeds the
273
+ * last-known state across restarts and receives every successful fetch. A 401
274
+ * during a fetch must call {@link invalidate}.
269
275
  */
270
276
  export class ModelCatalogCache {
277
+ persistence;
271
278
  ttlMs;
272
279
  entry;
273
- constructor(ttlMs = DISCOVERY_TTL_MS) {
280
+ inflight;
281
+ /** Settles once the persisted snapshot (when any) has been considered. */
282
+ seeded;
283
+ /** Set by {@link invalidate} so an in-flight disk read cannot resurrect dropped state. */
284
+ seedDisabled = false;
285
+ constructor(persistence, ttlMs = DISCOVERY_TTL_MS) {
286
+ this.persistence = persistence;
274
287
  this.ttlMs = ttlMs;
275
288
  }
276
289
  /**
@@ -282,21 +295,71 @@ export class ModelCatalogCache {
282
295
  return undefined;
283
296
  return this.entry.models;
284
297
  }
298
+ /** Load the persisted snapshot once; a fetch or invalidate that landed first wins. */
299
+ ensureSeeded() {
300
+ if (this.persistence === undefined)
301
+ return Promise.resolve();
302
+ this.seeded ??= this.persistence.load().then((snapshot) => {
303
+ if (snapshot !== undefined && this.entry === undefined && !this.seedDisabled) {
304
+ this.entry = snapshot;
305
+ }
306
+ }, () => undefined);
307
+ return this.seeded;
308
+ }
309
+ /** Run (or join) the single in-flight fetch, updating memory and disk on success. */
310
+ refresh(fetcher) {
311
+ this.inflight ??= fetcher()
312
+ .then((models) => {
313
+ const snapshot = { at: Date.now(), models };
314
+ this.entry = snapshot;
315
+ // Write-through is fire-and-forget: a failed save only costs durability.
316
+ void this.persistence?.save(snapshot).catch(() => undefined);
317
+ return models;
318
+ })
319
+ .finally(() => { this.inflight = undefined; });
320
+ return this.inflight;
321
+ }
285
322
  /**
286
323
  * Return the cached catalog when fresh, otherwise fetch and cache it.
287
324
  * @param fetcher - performs the provider's model-list request.
288
325
  * @returns the discovered models.
326
+ * @throws the fetcher's failure (the `listModels` caller warns and falls back).
289
327
  */
290
328
  async get(fetcher) {
291
- const cached = this.cached();
292
- if (cached !== undefined)
293
- return cached;
294
- const models = await fetcher();
295
- this.entry = { at: Date.now(), models };
296
- return models;
329
+ await this.ensureSeeded();
330
+ return this.cached() ?? this.refresh(fetcher);
331
+ }
332
+ /**
333
+ * The models for capability resolution. A fresh cache answers directly; a
334
+ * stale one answers immediately from the last-known catalog while a
335
+ * background refresh runs (a mid-conversation `resolveModel` must neither
336
+ * block on nor fail with the network); a cold cache awaits one fetch.
337
+ * @param fetcher - performs the provider's model-list request.
338
+ * @returns the models, or `undefined` when nothing is known (the caller
339
+ * falls back to its static metadata). Never throws.
340
+ */
341
+ async resolve(fetcher) {
342
+ await this.ensureSeeded();
343
+ const fresh = this.cached();
344
+ if (fresh !== undefined)
345
+ return fresh;
346
+ const known = this.entry?.models;
347
+ if (known !== undefined) {
348
+ // Stale-while-revalidate: the refresh outcome serves the NEXT resolve.
349
+ this.refresh(fetcher).catch(() => undefined);
350
+ return known;
351
+ }
352
+ try {
353
+ return await this.refresh(fetcher);
354
+ }
355
+ catch {
356
+ return undefined;
357
+ }
297
358
  }
298
359
  /** Drop the cached catalog (e.g. after a 401 proved the credential changed). */
299
360
  invalidate() {
300
361
  this.entry = undefined;
362
+ this.seedDisabled = true;
363
+ void this.persistence?.clear().catch(() => undefined);
301
364
  }
302
365
  }
@@ -9,7 +9,7 @@ import type { FlowSpec } from '../auth/oauth-flow.js';
9
9
  import type { GrokSession } from '../auth/store.js';
10
10
  import type { AttachmentStore } from '@deepseek-ai/dsh-attachment';
11
11
  import { TokenManager } from './common.js';
12
- import type { DiscoveredModel, FetchFn, ModelEntry, ProviderUsage } from './common.js';
12
+ import type { CatalogPersistence, DiscoveredModel, FetchFn, ModelEntry, ProviderUsage } from './common.js';
13
13
  export declare const GROK_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
14
14
  export declare const GROK_DISCOVERY_URL = "https://auth.x.ai/.well-known/openid-configuration";
15
15
  export declare const GROK_API_URL = "https://api.x.ai/v1/responses";
@@ -121,15 +121,28 @@ export interface GrokAdapterOptions {
121
121
  fetchFn?: FetchFn;
122
122
  /** Resolve the attachment service per request; absent means image requests fail loudly. */
123
123
  resolveAttachments?: () => AttachmentStore | undefined;
124
+ /** Durable catalog store seeding capability metadata across restarts. */
125
+ catalogStore?: CatalogPersistence;
124
126
  }
125
127
  /** Grok wire adapter: one instance serves the `grok` provider route. */
126
128
  export declare class GrokAdapter extends LlmAdapter {
127
129
  private readonly options;
128
130
  private readonly catalog;
129
131
  constructor(options: GrokAdapterOptions);
132
+ /** Discovery fetcher: resolves the session through the refresh-aware path. */
133
+ private fetchCatalog;
130
134
  providerInfo(provider: string): LlmProviderInfo;
131
135
  private staticModels;
132
136
  listModels(provider: string): Promise<readonly LlmModelInfo[]>;
137
+ /**
138
+ * The discovered entry for one model. Resolved through the cache's
139
+ * stale-while-revalidate path: capability metadata must stay stable across
140
+ * a long conversation — a session that selected a reasoning effort calls
141
+ * this on EVERY step, and forgetting the efforts just because the TTL
142
+ * lapsed mid-turn would fail the call with UNSUPPORTED_REASONING_EFFORT
143
+ * before provider I/O.
144
+ */
145
+ private discovered;
133
146
  resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo>;
134
147
  stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
135
148
  private request;