dsh-plugin-subscription-oauth 0.16.2 → 0.17.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.
Files changed (43) hide show
  1. package/README.dev.md +1 -1
  2. package/README.md +7 -6
  3. package/README.zh.dev.md +1 -1
  4. package/README.zh.md +7 -6
  5. package/lib/auth/workbuddy-flow.d.ts +1 -1
  6. package/lib/auth/workbuddy-flow.js +14 -9
  7. package/lib/client/index.js +10 -1
  8. package/lib/client.js +6 -1
  9. package/lib/client.js.map +1 -1
  10. package/lib/index.js +21 -0
  11. package/lib/providers/antigravity.d.ts +34 -4
  12. package/lib/providers/antigravity.js +100 -28
  13. package/lib/providers/ark-provider.d.ts +10 -0
  14. package/lib/providers/ark-provider.js +6 -0
  15. package/lib/providers/ark.d.ts +96 -10
  16. package/lib/providers/ark.js +176 -48
  17. package/lib/providers/catalog.d.ts +91 -0
  18. package/lib/providers/catalog.js +190 -0
  19. package/lib/providers/common.d.ts +14 -0
  20. package/lib/providers/common.js +23 -0
  21. package/lib/providers/glm-provider.d.ts +10 -0
  22. package/lib/providers/glm-provider.js +6 -0
  23. package/lib/providers/glm.d.ts +92 -16
  24. package/lib/providers/glm.js +172 -85
  25. package/lib/providers/grok.d.ts +106 -27
  26. package/lib/providers/grok.js +312 -128
  27. package/lib/providers/kimi-provider.d.ts +10 -0
  28. package/lib/providers/kimi-provider.js +8 -3
  29. package/lib/providers/kimi.d.ts +104 -32
  30. package/lib/providers/kimi.js +198 -129
  31. package/lib/providers/mimo-provider.d.ts +14 -1
  32. package/lib/providers/mimo-provider.js +17 -6
  33. package/lib/providers/mimo.d.ts +164 -19
  34. package/lib/providers/mimo.js +318 -19
  35. package/lib/providers/model-cache.d.ts +57 -0
  36. package/lib/providers/model-cache.js +105 -0
  37. package/lib/providers/responses-stream.d.ts +8 -0
  38. package/lib/providers/responses-stream.js +21 -5
  39. package/lib/providers/workbuddy-provider.d.ts +17 -0
  40. package/lib/providers/workbuddy-provider.js +14 -6
  41. package/lib/providers/workbuddy.d.ts +140 -13
  42. package/lib/providers/workbuddy.js +520 -55
  43. package/package.json +1 -1
@@ -22,6 +22,16 @@ export interface GlmProviderOptions {
22
22
  defaultModel?: string;
23
23
  /** Extra model ids published without shipped metadata. */
24
24
  extraModels?: readonly string[];
25
+ /**
26
+ * The endpoint's last live listing, from this plugin's cache.
27
+ *
28
+ * The cached table is what the picker shows before any request: the endpoint's own
29
+ * answer from the last read, so a release does not need a plugin update and a refresh
30
+ * does not need a request.
31
+ */
32
+ cachedListing?: () => Promise<unknown>;
33
+ /** Record what a live listing just produced, replacing the cached table. */
34
+ saveListing?: (payload: unknown) => Promise<void>;
25
35
  /** Shared model-visibility preferences. */
26
36
  preferences: ModelPreferences;
27
37
  /** Resolve the attachment service per request. */
@@ -116,8 +116,14 @@ export function createGlmProvider(options) {
116
116
  return read.windows;
117
117
  },
118
118
  ...options.extraModels === undefined ? {} : { extraModels: options.extraModels },
119
+ ...options.defaultModel === undefined ? {} : { defaultModel: options.defaultModel },
120
+ ...options.cachedListing === undefined ? {} : { cachedListing: options.cachedListing },
121
+ ...options.saveListing === undefined ? {} : { saveListing: options.saveListing },
119
122
  fetchFn,
120
123
  });
124
+ // Read the cached table now rather than at the first picker query: the Settings page
125
+ // reads the catalog synchronously, so a request-free start must already have it.
126
+ void adapter.hydrate().catch(() => undefined);
121
127
  const spec = {
122
128
  id: GLM_PROVIDER_ID,
123
129
  displayName: GLM_PROVIDER_NAME,
@@ -6,6 +6,11 @@
6
6
  * requests and the model list then go to the coding-plan host, whose
7
7
  * `/responses` route answers the same wire the `kimi` and `grok` routes use.
8
8
  *
9
+ * The model table this route publishes is that host's own `/models` answer, read once
10
+ * and kept in this plugin's cache (see `hydrate` and `fetchCatalog`). Nothing in this
11
+ * module decides which models exist, so one released, renamed or retired server-side
12
+ * reaches the picker without a plugin update and without a request per refresh.
13
+ *
9
14
  * @module dsh-plugin-subscription-oauth/providers/glm
10
15
  */
11
16
  import { LlmAdapter } from '@deepseek-ai/dsh-llm';
@@ -44,14 +49,6 @@ export declare const GLM_DEFAULT_MODEL = "glm-5.3";
44
49
  export declare const GLM_CONTEXT_WINDOW = 1048576;
45
50
  /** Output budget used when the caller sets no cap. */
46
51
  export declare const GLM_DEFAULT_MAX_TOKENS = 131072;
47
- /**
48
- * The catalog this build ships, so a signed-in route is usable with no discovery.
49
- *
50
- * Ids are the wire ids, lowercase: the coding plan answers `glm-5.3`, not the
51
- * `GLM-5.3` display name its own client shows. `glm-5.3-flash` is the multimodal
52
- * member of the family; the rest read text only.
53
- */
54
- export declare const GLM_MODELS: readonly CatalogEntry[];
55
52
  /** One signed-in BigModel account. */
56
53
  export interface GlmSession extends SessionBase {
57
54
  /** ZCode JWT the coding plan is bound to; presented as the API key. */
@@ -82,6 +79,25 @@ export interface GlmAdapterOptions {
82
79
  userModels?: () => Promise<readonly string[]> | readonly string[];
83
80
  /** Extra ids published without shipped metadata. */
84
81
  extraModels?: readonly string[];
82
+ /**
83
+ * Model this route preselects, and therefore always offers.
84
+ *
85
+ * Configuration rather than a shipped fact: a subscription that re-points its
86
+ * alias at a new release follows the config instead of this build's release
87
+ * cadence. Defaults to {@link GLM_DEFAULT_MODEL}.
88
+ */
89
+ defaultModel?: string;
90
+ /**
91
+ * The endpoint's last live listing, from this plugin's cache.
92
+ *
93
+ * Read once per session, before the first catalog query, so the picker offers real
94
+ * models without asking the endpoint anything. A route that fetched once and cached
95
+ * the answer has no reason to fetch again on every refresh; the Settings "refresh
96
+ * models" action is what re-reads it.
97
+ */
98
+ cachedListing?: () => Promise<unknown>;
99
+ /** Record what a live listing just produced, replacing the cached table. */
100
+ saveListing?: (payload: unknown) => Promise<void>;
85
101
  /** Resolve the attachment service per request. */
86
102
  resolveAttachments?: () => AttachmentStore | undefined;
87
103
  onWarn?: (message: string) => void;
@@ -118,11 +134,46 @@ export declare function readGlmUsage(input: {
118
134
  /** The adapter behind the `glm` route. */
119
135
  export declare class GlmAdapter extends LlmAdapter {
120
136
  private readonly options;
121
- /** Live catalog ids, once discovery succeeded this session. */
137
+ /**
138
+ * The catalog this route serves: entries derived from the endpoint's own listing,
139
+ * either the one just read or the one the cache kept from the last read.
140
+ *
141
+ * Undefined means neither source has produced a listing yet, so only the configured
142
+ * default model is offered until one does.
143
+ */
122
144
  private live;
123
- /** Image-generation model id, when discovery reported one. */
145
+ /** Whether the cached listing has been read; it is read at most once per session. */
146
+ private hydrated;
147
+ /** Image-generation model id, when the listing reported one. */
124
148
  private imageModel;
149
+ /**
150
+ * Fast-variant wire id per picker row.
151
+ *
152
+ * Derived from the listing rather than a curated alias map: a row whose name is a
153
+ * listed model plus the provider's own fast word rides that row instead of becoming
154
+ * one of its own. GLM's own `-flash` id is a distinct model, not a variant, and is
155
+ * left as a row.
156
+ */
157
+ private readonly fastVariants;
125
158
  constructor(options: GlmAdapterOptions);
159
+ /**
160
+ * Adopt the rows of one live listing as this route's catalog.
161
+ *
162
+ * Everything the picker shows is derived here from the endpoint's own answer: the
163
+ * rows that become picker entries and the fast variant that pairs with each row.
164
+ * Nothing about which models exist, or what each one is, is decided by this build.
165
+ *
166
+ * @param rows - decoded rows of the endpoint's listing, in the endpoint's order.
167
+ * @returns the entries to publish, in picker order.
168
+ */
169
+ private adoptListing;
170
+ /**
171
+ * Read the cached listing, once, so the picker works without asking the endpoint.
172
+ *
173
+ * Called before the first catalog query. A read failure is a cache miss, never a
174
+ * failure: the route simply has no listing until one is fetched.
175
+ */
176
+ hydrate(): Promise<void>;
126
177
  providerInfo(provider: string): LlmProviderInfo;
127
178
  providerRetryPolicy(): undefined;
128
179
  listModels(_provider: string): Promise<readonly LlmModelInfo[]>;
@@ -130,10 +181,21 @@ export declare class GlmAdapter extends LlmAdapter {
130
181
  stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
131
182
  /** Translate an auth failure into the message a user can act on. */
132
183
  private refreshHint;
133
- /** The catalog served: the shipped list, the live one, then hand-added ids. */
184
+ /** The catalog served: the route's own entries, then hand-added ids. */
134
185
  private catalog;
135
- /** The route's own catalog, without ids the user added by hand. */
186
+ /**
187
+ * The catalog to present: the models the endpoint's listing named, in its own order,
188
+ * each with the metadata that listing stated, then the configured extras.
189
+ *
190
+ * There is no shipped roster. A listing is read once and cached (see {@link hydrate}
191
+ * and {@link fetchCatalog}), and that cached table is what the picker shows until the
192
+ * next read, so a model released, renamed or retired server-side is reflected without
193
+ * a plugin update. Only the configured default is always offered, because the picker
194
+ * preselects it.
195
+ */
136
196
  private shippedCatalog;
197
+ /** The model the picker preselects, from configuration. */
198
+ private defaultModel;
137
199
  /** Ids the user published by hand that this route does not already serve. */
138
200
  private addedEntries;
139
201
  /** The image-generation model id, when discovery reported one. */
@@ -141,19 +203,33 @@ export declare class GlmAdapter extends LlmAdapter {
141
203
  /** The ids the picker must currently omit. */
142
204
  private hidden;
143
205
  /**
144
- * Adopt the endpoint's live catalog.
206
+ * Adopt the ids a caller reports as the served set, without a listing.
207
+ *
208
+ * {@link fetchCatalog} already derived the entries from the rows it read, and the
209
+ * controller reports those same ids back here; the entries it recorded are kept. A
210
+ * caller that only has ids (a test, another discovery path) gets conservative entries
211
+ * for them.
145
212
  *
146
- * @param ids - model ids the endpoint reported.
213
+ * @param ids - model ids the caller reports.
147
214
  */
148
215
  discover(ids: readonly string[]): void;
149
216
  /**
150
- * Read the live model list.
217
+ * Read the endpoint's model listing and adopt it as this route's catalog.
218
+ *
219
+ * This is the one place a model table is produced, and it is the endpoint's own
220
+ * answer: what the listing names is what the picker offers, with the display name,
221
+ * context window, modalities and reasoning ladder the listing states. The rows are
222
+ * also handed to {@link GlmAdapterOptions.saveListing}, so the next session starts
223
+ * from this table instead of asking again — the Settings "refresh models" action is
224
+ * what re-reads it.
151
225
  *
152
226
  * @param accessToken - bearer for the account.
153
227
  * @param signal - optional cancellation.
154
- * @returns the ids the endpoint serves, or undefined when it cannot be read.
228
+ * @returns the model ids now served, or undefined when the listing is unavailable.
155
229
  */
156
230
  fetchCatalog(accessToken: string, signal?: AbortSignal): Promise<readonly string[] | undefined>;
231
+ /** Persist one live listing, reporting a cache failure without failing the read. */
232
+ private saveListing;
157
233
  /** Send one streaming request over the Responses wire. */
158
234
  private request;
159
235
  /** Resolve image blocks through the attachment service, when it is present. */
@@ -6,16 +6,21 @@
6
6
  * requests and the model list then go to the coding-plan host, whose
7
7
  * `/responses` route answers the same wire the `kimi` and `grok` routes use.
8
8
  *
9
+ * The model table this route publishes is that host's own `/models` answer, read once
10
+ * and kept in this plugin's cache (see `hydrate` and `fetchCatalog`). Nothing in this
11
+ * module decides which models exist, so one released, renamed or retired server-side
12
+ * reaches the picker without a plugin update and without a request per refresh.
13
+ *
9
14
  * @module dsh-plugin-subscription-oauth/providers/glm
10
15
  */
11
16
  import { LlmAdapter, LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
12
17
  import { REAUTH_REQUIRED, ProviderError } from '../errors.js';
13
18
  import { finiteInstant, nonEmpty } from '../auth/store.js';
14
19
  import { proxiedFetch } from '../http.js';
15
- import { findEntry, resolveCatalog } from './catalog.js';
20
+ import { composeCatalog, fallbackEntry, findEntry, liveEntry, liveModelFacts, resolveCatalog } from './catalog.js';
16
21
  import { streamProviderResponses, accountPoolHooks } from './responses-stream.js';
17
22
  import { toResponsesInput, toResponsesTools } from '../translate/responses.js';
18
- import { isImageModel } from './grok.js';
23
+ import { baseModelOf, FastVariantIndex, isFastVariant, isImageModel } from './grok.js';
19
24
  /** Model ids that are clearly not chat models. */
20
25
  const NON_CHAT_ID = /(?:^|[-_/])(?:image|video|imagine|embed|embedding|whisper|tts|vision-only)/i;
21
26
  /** Provider route this module serves. */
@@ -45,54 +50,6 @@ export const GLM_DEFAULT_MODEL = 'glm-5.3';
45
50
  export const GLM_CONTEXT_WINDOW = 1_048_576;
46
51
  /** Output budget used when the caller sets no cap. */
47
52
  export const GLM_DEFAULT_MAX_TOKENS = 131_072;
48
- /** Reasoning ladder the GLM 5.3 family accepts. */
49
- const GLM_53_EFFORTS = ['low', 'high', 'max'];
50
- /**
51
- * The catalog this build ships, so a signed-in route is usable with no discovery.
52
- *
53
- * Ids are the wire ids, lowercase: the coding plan answers `glm-5.3`, not the
54
- * `GLM-5.3` display name its own client shows. `glm-5.3-flash` is the multimodal
55
- * member of the family; the rest read text only.
56
- */
57
- export const GLM_MODELS = [
58
- {
59
- id: 'glm-5.3',
60
- name: 'GLM-5.3',
61
- description: 'Coding plan flagship, text only',
62
- contextWindow: GLM_CONTEXT_WINDOW,
63
- inputModalities: ['text'],
64
- reasoningEfforts: [...GLM_53_EFFORTS],
65
- },
66
- {
67
- id: 'glm-5.3-flash',
68
- name: 'GLM-5.3 Flash',
69
- description: 'Fast, reads images',
70
- contextWindow: GLM_CONTEXT_WINDOW,
71
- inputModalities: ['text', 'image'],
72
- reasoningEfforts: [...GLM_53_EFFORTS],
73
- },
74
- {
75
- id: 'glm-5.2',
76
- name: 'GLM-5.2',
77
- description: 'Previous flagship',
78
- contextWindow: GLM_CONTEXT_WINDOW,
79
- inputModalities: ['text'],
80
- },
81
- {
82
- id: 'glm-5.1',
83
- name: 'GLM-5.1',
84
- description: 'Earlier 5.x release',
85
- contextWindow: GLM_CONTEXT_WINDOW,
86
- inputModalities: ['text'],
87
- },
88
- {
89
- id: 'glm-5',
90
- name: 'GLM-5',
91
- description: 'First 5.x release',
92
- contextWindow: GLM_CONTEXT_WINDOW,
93
- inputModalities: ['text'],
94
- },
95
- ];
96
53
  /**
97
54
  * Coerce one decoded credential entry into a GLM session.
98
55
  *
@@ -221,14 +178,88 @@ async function httpFailure(response, label) {
221
178
  /** The adapter behind the `glm` route. */
222
179
  export class GlmAdapter extends LlmAdapter {
223
180
  options;
224
- /** Live catalog ids, once discovery succeeded this session. */
181
+ /**
182
+ * The catalog this route serves: entries derived from the endpoint's own listing,
183
+ * either the one just read or the one the cache kept from the last read.
184
+ *
185
+ * Undefined means neither source has produced a listing yet, so only the configured
186
+ * default model is offered until one does.
187
+ */
225
188
  live;
226
- /** Image-generation model id, when discovery reported one. */
189
+ /** Whether the cached listing has been read; it is read at most once per session. */
190
+ hydrated = false;
191
+ /** Image-generation model id, when the listing reported one. */
227
192
  imageModel;
193
+ /**
194
+ * Fast-variant wire id per picker row.
195
+ *
196
+ * Derived from the listing rather than a curated alias map: a row whose name is a
197
+ * listed model plus the provider's own fast word rides that row instead of becoming
198
+ * one of its own. GLM's own `-flash` id is a distinct model, not a variant, and is
199
+ * left as a row.
200
+ */
201
+ fastVariants = new FastVariantIndex();
228
202
  constructor(options) {
229
203
  super();
230
204
  this.options = options;
231
205
  }
206
+ /**
207
+ * Adopt the rows of one live listing as this route's catalog.
208
+ *
209
+ * Everything the picker shows is derived here from the endpoint's own answer: the
210
+ * rows that become picker entries and the fast variant that pairs with each row.
211
+ * Nothing about which models exist, or what each one is, is decided by this build.
212
+ *
213
+ * @param rows - decoded rows of the endpoint's listing, in the endpoint's order.
214
+ * @returns the entries to publish, in picker order.
215
+ */
216
+ adoptListing(rows) {
217
+ const facts = rows
218
+ .map((row) => liveModelFacts(row))
219
+ .filter((fact) => fact !== undefined);
220
+ // Image models are never picker rows: they are agent-called, always on.
221
+ this.imageModel = facts.find((fact) => isImageModel(fact.id))?.id;
222
+ this.fastVariants.clear();
223
+ for (const fact of facts)
224
+ this.fastVariants.noteListed(fact.id);
225
+ for (const fact of facts) {
226
+ const base = this.fastVariants.pair(fact.id);
227
+ this.fastVariants.note(fact.id, () => base ?? baseModelOf(fact.id));
228
+ }
229
+ // Fold a variant onto its row and drop the ids that are not chat models, keeping the
230
+ // endpoint's order. A row's own facts describe the row; a variant's describe the
231
+ // variant, so a row that is also listed is never overwritten by its variant.
232
+ const byId = new Map();
233
+ for (const fact of facts) {
234
+ if (NON_CHAT_ID.test(fact.id))
235
+ continue;
236
+ const id = isFastVariant(fact.id) ? this.fastVariants.pair(fact.id) ?? baseModelOf(fact.id) : fact.id;
237
+ if (id === fact.id || !byId.has(id))
238
+ byId.set(id, { ...fact, id });
239
+ }
240
+ return [...byId.values()].map((fact) => liveEntry(fact, [], GLM_CONTEXT_WINDOW, 'Reported by the GLM model list'));
241
+ }
242
+ /**
243
+ * Read the cached listing, once, so the picker works without asking the endpoint.
244
+ *
245
+ * Called before the first catalog query. A read failure is a cache miss, never a
246
+ * failure: the route simply has no listing until one is fetched.
247
+ */
248
+ async hydrate() {
249
+ if (this.hydrated)
250
+ return;
251
+ this.hydrated = true;
252
+ if (this.live !== undefined || this.options.cachedListing === undefined)
253
+ return;
254
+ try {
255
+ const rows = listingRows(await this.options.cachedListing());
256
+ if (rows.length > 0)
257
+ this.live = this.adoptListing(rows);
258
+ }
259
+ catch (error) {
260
+ this.options.onWarn?.(`cached model catalog unavailable (${error instanceof Error ? error.message : String(error)})`);
261
+ }
262
+ }
232
263
  providerInfo(provider) {
233
264
  return { id: provider, name: GLM_PROVIDER_NAME };
234
265
  }
@@ -242,6 +273,7 @@ export class GlmAdapter extends LlmAdapter {
242
273
  // must not appear in the picker, and Auto must not try it.
243
274
  if (await this.options.tokens.session() === undefined)
244
275
  return [];
276
+ await this.hydrate();
245
277
  const hidden = await this.hidden();
246
278
  // Image models are never picker rows: they are agent-called, always on.
247
279
  const imageless = this.catalog().filter((entry) => !isImageModel(entry.id));
@@ -254,11 +286,12 @@ export class GlmAdapter extends LlmAdapter {
254
286
  }));
255
287
  }
256
288
  async resolveModel(_provider, model) {
289
+ await this.hydrate();
257
290
  const entry = findEntry(this.catalog(), model);
258
291
  if (entry === undefined) {
259
292
  // An id this build has never heard of is still dispatchable: the endpoint is
260
293
  // the authority on what it serves, and a newer model should not be refused.
261
- const template = findEntry(this.catalog(), GLM_DEFAULT_MODEL) ?? this.catalog()[0];
294
+ const template = findEntry(this.catalog(), this.defaultModel()) ?? this.catalog()[0];
262
295
  return {
263
296
  provider: GLM_PROVIDER_ID,
264
297
  id: model,
@@ -304,24 +337,34 @@ export class GlmAdapter extends LlmAdapter {
304
337
  }
305
338
  return error;
306
339
  }
307
- /** The catalog served: the shipped list, the live one, then hand-added ids. */
340
+ /** The catalog served: the route's own entries, then hand-added ids. */
308
341
  catalog() {
309
342
  return [...this.shippedCatalog(), ...this.addedEntries()];
310
343
  }
311
- /** The route's own catalog, without ids the user added by hand. */
344
+ /**
345
+ * The catalog to present: the models the endpoint's listing named, in its own order,
346
+ * each with the metadata that listing stated, then the configured extras.
347
+ *
348
+ * There is no shipped roster. A listing is read once and cached (see {@link hydrate}
349
+ * and {@link fetchCatalog}), and that cached table is what the picker shows until the
350
+ * next read, so a model released, renamed or retired server-side is reflected without
351
+ * a plugin update. Only the configured default is always offered, because the picker
352
+ * preselects it.
353
+ */
312
354
  shippedCatalog() {
313
- const shipped = this.options.extraModels === undefined
314
- ? [...GLM_MODELS]
315
- : [...GLM_MODELS, ...this.options.extraModels.map((id) => extraEntry(id))];
316
- const live = this.live;
317
- if (live === undefined)
318
- return shipped;
319
- const merged = [...shipped];
320
- for (const id of live) {
321
- if (findEntry(merged, id) === undefined)
322
- merged.push(extraEntry(id));
323
- }
324
- return merged;
355
+ return composeCatalog({
356
+ curated: [],
357
+ ...this.live === undefined ? {} : { live: this.live },
358
+ ...this.options.extraModels === undefined ? {} : { extra: this.options.extraModels },
359
+ defaultModel: this.defaultModel(),
360
+ fallbackContextWindow: GLM_CONTEXT_WINDOW,
361
+ liveDescription: 'Reported by the GLM model list',
362
+ extraDescription: 'Extra model from the plugin configuration',
363
+ });
364
+ }
365
+ /** The model the picker preselects, from configuration. */
366
+ defaultModel() {
367
+ return this.options.defaultModel ?? GLM_DEFAULT_MODEL;
325
368
  }
326
369
  /** Ids the user published by hand that this route does not already serve. */
327
370
  addedEntries() {
@@ -331,7 +374,7 @@ export class GlmAdapter extends LlmAdapter {
331
374
  return [];
332
375
  return (Array.isArray(added) ? added : [])
333
376
  .filter((id) => findEntry(own, id) === undefined)
334
- .map((id) => extraEntry(id));
377
+ .map((id) => fallbackEntry(id, 'Added by hand in Settings', GLM_CONTEXT_WINDOW));
335
378
  }
336
379
  /** The image-generation model id, when discovery reported one. */
337
380
  imageModelId() {
@@ -350,19 +393,33 @@ export class GlmAdapter extends LlmAdapter {
350
393
  }
351
394
  }
352
395
  /**
353
- * Adopt the endpoint's live catalog.
396
+ * Adopt the ids a caller reports as the served set, without a listing.
354
397
  *
355
- * @param ids - model ids the endpoint reported.
398
+ * {@link fetchCatalog} already derived the entries from the rows it read, and the
399
+ * controller reports those same ids back here; the entries it recorded are kept. A
400
+ * caller that only has ids (a test, another discovery path) gets conservative entries
401
+ * for them.
402
+ *
403
+ * @param ids - model ids the caller reports.
356
404
  */
357
405
  discover(ids) {
358
- this.live = [...ids];
406
+ const known = new Map((this.live ?? []).map((entry) => [entry.id, entry]));
407
+ this.live = ids.map((id) => known.get(id)
408
+ ?? liveEntry({ id }, [], GLM_CONTEXT_WINDOW, 'Reported by the GLM model list'));
359
409
  }
360
410
  /**
361
- * Read the live model list.
411
+ * Read the endpoint's model listing and adopt it as this route's catalog.
412
+ *
413
+ * This is the one place a model table is produced, and it is the endpoint's own
414
+ * answer: what the listing names is what the picker offers, with the display name,
415
+ * context window, modalities and reasoning ladder the listing states. The rows are
416
+ * also handed to {@link GlmAdapterOptions.saveListing}, so the next session starts
417
+ * from this table instead of asking again — the Settings "refresh models" action is
418
+ * what re-reads it.
362
419
  *
363
420
  * @param accessToken - bearer for the account.
364
421
  * @param signal - optional cancellation.
365
- * @returns the ids the endpoint serves, or undefined when it cannot be read.
422
+ * @returns the model ids now served, or undefined when the listing is unavailable.
366
423
  */
367
424
  async fetchCatalog(accessToken, signal) {
368
425
  if (!this.options.discovery)
@@ -374,23 +431,40 @@ export class GlmAdapter extends LlmAdapter {
374
431
  ...signal === undefined ? {} : { signal },
375
432
  });
376
433
  if (!response.ok) {
377
- this.options.onWarn?.(`GLM model list unavailable (HTTP ${response.status})`);
434
+ this.options.onWarn?.(`GLM model list unavailable (HTTP ${response.status}); keeping the last catalog`);
378
435
  return undefined;
379
436
  }
380
- const body = await response.json();
381
- const allIds = (body.data ?? [])
382
- .map((entry) => (typeof entry?.id === 'string' ? entry.id.trim() : ''))
383
- .filter((id) => id !== '');
384
- // Image models are never picker rows: they are agent-called, always on.
385
- this.imageModel = allIds.find((id) => isImageModel(id));
386
- const ids = allIds.filter((id) => !NON_CHAT_ID.test(id));
387
- return ids.length === 0 ? undefined : ids;
437
+ const payload = await response.json();
438
+ const rows = Array.isArray(payload.data) ? payload.data : [];
439
+ const entries = this.adoptListing(rows);
440
+ if (entries.length === 0) {
441
+ this.options.onWarn?.('GLM model discovery returned an empty catalog; keeping the last catalog');
442
+ return undefined;
443
+ }
444
+ this.live = entries;
445
+ // The cached table is the endpoint's answer, kept so the next session does not ask
446
+ // again. A cache failure never fails the read that produced it.
447
+ await this.saveListing(rows);
448
+ return entries.map((entry) => entry.id);
388
449
  }
389
450
  catch (error) {
451
+ if (signal?.aborted === true)
452
+ return undefined;
390
453
  this.options.onWarn?.(`GLM model list unavailable (${error instanceof Error ? error.message : String(error)})`);
391
454
  return undefined;
392
455
  }
393
456
  }
457
+ /** Persist one live listing, reporting a cache failure without failing the read. */
458
+ async saveListing(rows) {
459
+ if (this.options.saveListing === undefined)
460
+ return;
461
+ try {
462
+ await this.options.saveListing(rows);
463
+ }
464
+ catch (error) {
465
+ this.options.onWarn?.(`model catalog cache write failed (${error instanceof Error ? error.message : String(error)})`);
466
+ }
467
+ }
394
468
  /** Send one streaming request over the Responses wire. */
395
469
  async request(options, model, accessToken, signal) {
396
470
  const resolveImage = this.imageResolver(signal);
@@ -446,9 +520,22 @@ export class GlmAdapter extends LlmAdapter {
446
520
  return this.catalog();
447
521
  }
448
522
  }
449
- /** An entry for an id this build ships no metadata for. */
450
- function extraEntry(id) {
451
- return { id, name: id, contextWindow: GLM_CONTEXT_WINDOW };
523
+ /**
524
+ * The rows of one cached listing.
525
+ *
526
+ * The cache holds the rows this plugin stored; a payload written as the endpoint's own
527
+ * envelope is read too, so a differently shaped cache cannot silently empty the picker.
528
+ *
529
+ * @param payload - whatever `cachedListing` returned.
530
+ * @returns the rows to adopt, or an empty list when the payload names none.
531
+ */
532
+ function listingRows(payload) {
533
+ if (Array.isArray(payload))
534
+ return payload;
535
+ const data = typeof payload === 'object' && payload !== null
536
+ ? payload.data
537
+ : undefined;
538
+ return Array.isArray(data) ? data : [];
452
539
  }
453
540
  /** The reasoning block for a catalog entry, or undefined when it supports none. */
454
541
  function reasoningInfo(entry) {