dsh-plugin-subscription-oauth 0.16.3 → 0.17.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.
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/client/FastModeButton.d.ts +0 -1
  6. package/lib/client/FastModeButton.js +48 -11
  7. package/lib/client.js +32 -9
  8. package/lib/client.js.map +1 -1
  9. package/lib/index.js +21 -0
  10. package/lib/protocol.d.ts +9 -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 +16 -2
  15. package/lib/providers/ark.d.ts +104 -10
  16. package/lib/providers/ark.js +186 -48
  17. package/lib/providers/catalog.d.ts +92 -0
  18. package/lib/providers/catalog.js +191 -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 +16 -2
  23. package/lib/providers/glm.d.ts +100 -16
  24. package/lib/providers/glm.js +182 -85
  25. package/lib/providers/grok.d.ts +114 -27
  26. package/lib/providers/grok.js +330 -129
  27. package/lib/providers/kimi-provider.d.ts +10 -0
  28. package/lib/providers/kimi-provider.js +16 -4
  29. package/lib/providers/kimi.d.ts +122 -32
  30. package/lib/providers/kimi.js +243 -122
  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 +501 -59
  43. package/package.json +1 -1
@@ -141,6 +141,29 @@ export function isMissingOrInvalidCredential(error) {
141
141
  }
142
142
  return false;
143
143
  }
144
+ /** Body text that says the request named a model the account does not serve. */
145
+ const UNKNOWN_MODEL = /model id does not exist|unknown model|model (?:id )?not found|does not exist, recognized as/i;
146
+ /** Body text that names the model id to use instead. */
147
+ const MODEL_REPLACEMENT = /set model id as\s*`?([A-Za-z0-9][A-Za-z0-9._:@[\]/-]*)`?/i;
148
+ /**
149
+ * The model id a provider's failure names as the one to use instead.
150
+ *
151
+ * A 401/403 is not always about the credential: a provider also answers it when the
152
+ * request names a model id the account does not serve — Kimi says `Your model id does
153
+ * not exist, recognized as other:k3[1m]. Please set model id as \`k3\``. That message
154
+ * is the endpoint's own answer to "which id should I have used", so it is worth acting
155
+ * on rather than reporting: refreshing a healthy token, or marking the account as
156
+ * needing a new login, would both be wrong answers to a model rename.
157
+ *
158
+ * @param message - the failure text.
159
+ * @returns the replacement id, or undefined when the failure is not about the model id.
160
+ */
161
+ export function unknownModelReplacement(message) {
162
+ if (!UNKNOWN_MODEL.test(message))
163
+ return undefined;
164
+ const replacement = MODEL_REPLACEMENT.exec(message)?.[1];
165
+ return replacement === undefined || replacement === '' ? undefined : replacement;
166
+ }
144
167
  /**
145
168
  * Map a thrown transport failure onto a harness error, distinguishing caller
146
169
  * cancellation from an idle timeout from a genuine network fault.
@@ -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,
@@ -166,9 +172,17 @@ export function createGlmProvider(options) {
166
172
  removeModel: (id) => controller.removeModel(id),
167
173
  usage: (force) => controller.usage(force),
168
174
  reload: () => controller.reload(),
169
- modelVariants: (id) => {
175
+ modelVariants: async (id) => {
176
+ // The image model is a row of the listing, so the read is awaited before answering:
177
+ // the composer asks once, and "no image model" must mean there is none.
178
+ void id;
179
+ await adapter.hydrate();
170
180
  const image = adapter.imageModelId();
171
- return image === undefined ? undefined : { image };
181
+ return {
182
+ ...image === undefined ? {} : { image },
183
+ // Nothing read yet: the answer above is provisional, and the caller is told so.
184
+ ...adapter.hasListing() ? {} : { pending: true },
185
+ };
172
186
  },
173
187
  };
174
188
  }
@@ -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,30 +181,63 @@ 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. */
140
202
  imageModelId(): string | undefined;
203
+ /**
204
+ * Whether this route has read a model listing yet.
205
+ *
206
+ * The image model is a row of the listing, so until one has been read "there is no image
207
+ * model" and "the listing has not arrived" look identical from outside; a caller that asks
208
+ * once needs to tell them apart.
209
+ */
210
+ hasListing(): boolean;
141
211
  /** The ids the picker must currently omit. */
142
212
  private hidden;
143
213
  /**
144
- * Adopt the endpoint's live catalog.
214
+ * Adopt the ids a caller reports as the served set, without a listing.
215
+ *
216
+ * {@link fetchCatalog} already derived the entries from the rows it read, and the
217
+ * controller reports those same ids back here; the entries it recorded are kept. A
218
+ * caller that only has ids (a test, another discovery path) gets conservative entries
219
+ * for them.
145
220
  *
146
- * @param ids - model ids the endpoint reported.
221
+ * @param ids - model ids the caller reports.
147
222
  */
148
223
  discover(ids: readonly string[]): void;
149
224
  /**
150
- * Read the live model list.
225
+ * Read the endpoint's model listing and adopt it as this route's catalog.
226
+ *
227
+ * This is the one place a model table is produced, and it is the endpoint's own
228
+ * answer: what the listing names is what the picker offers, with the display name,
229
+ * context window, modalities and reasoning ladder the listing states. The rows are
230
+ * also handed to {@link GlmAdapterOptions.saveListing}, so the next session starts
231
+ * from this table instead of asking again — the Settings "refresh models" action is
232
+ * what re-reads it.
151
233
  *
152
234
  * @param accessToken - bearer for the account.
153
235
  * @param signal - optional cancellation.
154
- * @returns the ids the endpoint serves, or undefined when it cannot be read.
236
+ * @returns the model ids now served, or undefined when the listing is unavailable.
155
237
  */
156
238
  fetchCatalog(accessToken: string, signal?: AbortSignal): Promise<readonly string[] | undefined>;
239
+ /** Persist one live listing, reporting a cache failure without failing the read. */
240
+ private saveListing;
157
241
  /** Send one streaming request over the Responses wire. */
158
242
  private request;
159
243
  /** 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,12 +374,22 @@ 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() {
338
381
  return this.imageModel;
339
382
  }
383
+ /**
384
+ * Whether this route has read a model listing yet.
385
+ *
386
+ * The image model is a row of the listing, so until one has been read "there is no image
387
+ * model" and "the listing has not arrived" look identical from outside; a caller that asks
388
+ * once needs to tell them apart.
389
+ */
390
+ hasListing() {
391
+ return this.live !== undefined;
392
+ }
340
393
  /** The ids the picker must currently omit. */
341
394
  async hidden() {
342
395
  if (this.options.hiddenModels === undefined)
@@ -350,19 +403,33 @@ export class GlmAdapter extends LlmAdapter {
350
403
  }
351
404
  }
352
405
  /**
353
- * Adopt the endpoint's live catalog.
406
+ * Adopt the ids a caller reports as the served set, without a listing.
407
+ *
408
+ * {@link fetchCatalog} already derived the entries from the rows it read, and the
409
+ * controller reports those same ids back here; the entries it recorded are kept. A
410
+ * caller that only has ids (a test, another discovery path) gets conservative entries
411
+ * for them.
354
412
  *
355
- * @param ids - model ids the endpoint reported.
413
+ * @param ids - model ids the caller reports.
356
414
  */
357
415
  discover(ids) {
358
- this.live = [...ids];
416
+ const known = new Map((this.live ?? []).map((entry) => [entry.id, entry]));
417
+ this.live = ids.map((id) => known.get(id)
418
+ ?? liveEntry({ id }, [], GLM_CONTEXT_WINDOW, 'Reported by the GLM model list'));
359
419
  }
360
420
  /**
361
- * Read the live model list.
421
+ * Read the endpoint's model listing and adopt it as this route's catalog.
422
+ *
423
+ * This is the one place a model table is produced, and it is the endpoint's own
424
+ * answer: what the listing names is what the picker offers, with the display name,
425
+ * context window, modalities and reasoning ladder the listing states. The rows are
426
+ * also handed to {@link GlmAdapterOptions.saveListing}, so the next session starts
427
+ * from this table instead of asking again — the Settings "refresh models" action is
428
+ * what re-reads it.
362
429
  *
363
430
  * @param accessToken - bearer for the account.
364
431
  * @param signal - optional cancellation.
365
- * @returns the ids the endpoint serves, or undefined when it cannot be read.
432
+ * @returns the model ids now served, or undefined when the listing is unavailable.
366
433
  */
367
434
  async fetchCatalog(accessToken, signal) {
368
435
  if (!this.options.discovery)
@@ -374,23 +441,40 @@ export class GlmAdapter extends LlmAdapter {
374
441
  ...signal === undefined ? {} : { signal },
375
442
  });
376
443
  if (!response.ok) {
377
- this.options.onWarn?.(`GLM model list unavailable (HTTP ${response.status})`);
444
+ this.options.onWarn?.(`GLM model list unavailable (HTTP ${response.status}); keeping the last catalog`);
378
445
  return undefined;
379
446
  }
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;
447
+ const payload = await response.json();
448
+ const rows = Array.isArray(payload.data) ? payload.data : [];
449
+ const entries = this.adoptListing(rows);
450
+ if (entries.length === 0) {
451
+ this.options.onWarn?.('GLM model discovery returned an empty catalog; keeping the last catalog');
452
+ return undefined;
453
+ }
454
+ this.live = entries;
455
+ // The cached table is the endpoint's answer, kept so the next session does not ask
456
+ // again. A cache failure never fails the read that produced it.
457
+ await this.saveListing(rows);
458
+ return entries.map((entry) => entry.id);
388
459
  }
389
460
  catch (error) {
461
+ if (signal?.aborted === true)
462
+ return undefined;
390
463
  this.options.onWarn?.(`GLM model list unavailable (${error instanceof Error ? error.message : String(error)})`);
391
464
  return undefined;
392
465
  }
393
466
  }
467
+ /** Persist one live listing, reporting a cache failure without failing the read. */
468
+ async saveListing(rows) {
469
+ if (this.options.saveListing === undefined)
470
+ return;
471
+ try {
472
+ await this.options.saveListing(rows);
473
+ }
474
+ catch (error) {
475
+ this.options.onWarn?.(`model catalog cache write failed (${error instanceof Error ? error.message : String(error)})`);
476
+ }
477
+ }
394
478
  /** Send one streaming request over the Responses wire. */
395
479
  async request(options, model, accessToken, signal) {
396
480
  const resolveImage = this.imageResolver(signal);
@@ -446,9 +530,22 @@ export class GlmAdapter extends LlmAdapter {
446
530
  return this.catalog();
447
531
  }
448
532
  }
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 };
533
+ /**
534
+ * The rows of one cached listing.
535
+ *
536
+ * The cache holds the rows this plugin stored; a payload written as the endpoint's own
537
+ * envelope is read too, so a differently shaped cache cannot silently empty the picker.
538
+ *
539
+ * @param payload - whatever `cachedListing` returned.
540
+ * @returns the rows to adopt, or an empty list when the payload names none.
541
+ */
542
+ function listingRows(payload) {
543
+ if (Array.isArray(payload))
544
+ return payload;
545
+ const data = typeof payload === 'object' && payload !== null
546
+ ? payload.data
547
+ : undefined;
548
+ return Array.isArray(data) ? data : [];
452
549
  }
453
550
  /** The reasoning block for a catalog entry, or undefined when it supports none. */
454
551
  function reasoningInfo(entry) {