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
@@ -12,7 +12,7 @@ import { REAUTH_REQUIRED, ProviderError } from '../errors.js';
12
12
  import { finiteInstant, nonEmpty } from '../auth/store.js';
13
13
  import { idleWatchdog, httpLlmError, isMissingOrInvalidCredential, mapFetchFailure } from './common.js';
14
14
  import { streamProviderResponses, accountPoolHooks } from './responses-stream.js';
15
- import { fallbackEntry, findEntry, resolveCatalog } from './catalog.js';
15
+ import { composeCatalog, fallbackEntry, findEntry, liveEntry, liveModelFacts, resolveCatalog } from './catalog.js';
16
16
  import { streamResponses, toResponsesInput, toResponsesTools } from '../translate/responses.js';
17
17
  import { prepareMoonshotTool } from '../translate/moonshot-schema.js';
18
18
  import { isImageModel, isFastVariant, baseModelOf, fastWordOf, FastVariantIndex } from './grok.js';
@@ -72,52 +72,45 @@ const EFFORT_LABELS = {
72
72
  [FAST_RUNG]: { name: 'Highspeed', description: 'Kimi highspeed variant of this model' },
73
73
  };
74
74
  /**
75
- * Kimi Code's live coding catalog.
75
+ * Stable labels for the ids this route is known for.
76
76
  *
77
- * `kimi-for-coding` is the stable subscription alias Moonshot re-points at each
78
- * coding release; `k3` is the current flagship, with the `[1m]` suffix selecting
79
- * its 1M-token window.
77
+ * Labels, not a roster: nothing here decides which models exist or what they serve —
78
+ * membership, windows, ladders and modalities are the endpoint's listing. What it fixes is
79
+ * the one thing the listing cannot answer, because the id does not name it: `kimi-for-coding`
80
+ * is the subscription alias the service re-points at each coding release, so its
81
+ * `display_name` names the release of the moment (`K2.8 Preview` the week K2.8 ships) while
82
+ * the id the user selected stays the same. Labelling that row by release would rename the
83
+ * user's saved selection overnight, so the alias keeps its own name — and the endpoint's
84
+ * release name is deliberately not shown, because the row is the alias, not the version.
85
+ *
86
+ * A model the endpoint adds that this map does not name is published exactly as the
87
+ * endpoint describes it.
80
88
  */
81
- export const KIMI_MODELS = [
82
- {
83
- id: 'kimi-for-coding',
89
+ const STABLE_MODEL_LABELS = {
90
+ 'kimi-for-coding': {
84
91
  name: 'Kimi for Coding',
85
- description: 'Stable subscription alias — 1M-token window',
86
- aliases: ['kimi-for-coding-highspeed', 'kimi-k2.7-code', 'kimi-k2.7-code-highspeed', 'kimi-k2.6', 'kimi-k2.5'],
87
- contextWindow: KIMI_LONG_CONTEXT_WINDOW,
88
- inputModalities: ['text', 'image'],
89
- reasoningEfforts: ['low', 'medium', 'high', 'max'],
90
- defaultEffort: 'max',
92
+ description: 'Subscription alias — the endpoint re-points it at the current coding release',
91
93
  },
92
- {
93
- id: 'k3',
94
+ 'k3': {
94
95
  name: 'Kimi K3',
95
96
  description: 'Flagship reasoning model',
96
- aliases: ['kimi-k3'],
97
- contextWindow: KIMI_CONTEXT_WINDOW,
98
- inputModalities: ['text', 'image'],
99
- reasoningEfforts: ['low', 'medium', 'high', 'max'],
100
- defaultEffort: 'max',
101
- },
102
- {
103
- id: 'k3[1m]',
104
- name: 'Kimi K3 (1M)',
105
- description: 'Flagship reasoning model with the 1M-token window',
106
- contextWindow: KIMI_LONG_CONTEXT_WINDOW,
107
- inputModalities: ['text', 'image'],
108
- reasoningEfforts: ['low', 'medium', 'high', 'max'],
109
- defaultEffort: 'max',
110
97
  },
111
- {
112
- id: 'k3-256k',
98
+ 'k3-256k': {
113
99
  name: 'Kimi K3 (256K)',
114
- description: 'Flagship reasoning model pinned to the standard window',
115
- contextWindow: KIMI_CONTEXT_WINDOW,
116
- inputModalities: ['text', 'image'],
117
- reasoningEfforts: ['low', 'medium', 'high', 'max'],
118
- defaultEffort: 'max',
100
+ description: 'Flagship reasoning model pinned to the 256K-token window',
119
101
  },
120
- ];
102
+ };
103
+ /**
104
+ * Spellings the endpoint has retired, and the model each one became.
105
+ *
106
+ * A repair table, not a roster: nothing here decides which models exist. Each entry
107
+ * is consulted only for an id the live listing does not itself serve, so a spelling
108
+ * the endpoint reintroduces is dispatched exactly as the endpoint names it. The
109
+ * endpoint's own 401 message (`Please set model id as \`k3\``) is the general case
110
+ * and needs no table at all; this only saves the first failed round trip for a
111
+ * spelling already known to be gone.
112
+ */
113
+ const RETIRED_MODEL_IDS = { 'k3[1m]': 'k3' };
121
114
  /** One `reasoning.efforts` entry for a resolved model. */
122
115
  function effortInfo(effort) {
123
116
  const label = EFFORT_LABELS[effort] ?? { name: effort, description: '' };
@@ -145,48 +138,132 @@ function reasoningInfo(entry, fastWireId) {
145
138
  /** Kimi Code adapter serving the `kimi` provider route. */
146
139
  export class KimiAdapter extends LlmAdapter {
147
140
  options;
148
- /** Live catalog ids seen this session, used to accept renamed models. */
149
- discovered;
150
- /** Image-generation model id, when discovery reported one. */
141
+ /**
142
+ * The catalog this route serves: entries derived from the endpoint's own listing,
143
+ * either the one just read or the one the cache kept from the last read.
144
+ *
145
+ * Undefined means neither source has produced a listing yet, so only the configured
146
+ * default model is offered until one does.
147
+ */
148
+ live;
149
+ /** Whether the cached listing has been read; it is read at most once per session. */
150
+ hydrated = false;
151
+ /** Image-generation model id, when the listing reported one. */
151
152
  imageModel;
152
153
  /**
153
154
  * Fast-variant wire id per picker row, for the composer's quick-mode button.
154
155
  *
155
- * Seeded from the curated aliases — `kimi-for-coding-highspeed` is one — so the button
156
- * exists before any discovery run, and then updated from the live catalog, which is
157
- * the authority on what this account actually serves.
156
+ * Derived from the listing: `kimi-for-coding-highspeed` is a row of its own there,
157
+ * and pairs with `kimi-for-coding` because its name is that of a listed model plus
158
+ * the provider's own fast word.
158
159
  */
159
160
  fastVariants = new FastVariantIndex();
160
161
  /** Ids the user added by hand, cached so the sync catalog can name them. */
161
162
  userAddedIds = [];
163
+ /**
164
+ * Model ids the endpoint renamed during this session, and what each became.
165
+ *
166
+ * Learned from the endpoint's own refusal (`Please set model id as \`k3\``), so a
167
+ * spelling retired after this build shipped is repaired without a plugin update. The
168
+ * first turn pays one refused request; every later turn goes straight to the current id.
169
+ */
170
+ renamed = new Map();
162
171
  /** Tools already reported as flattened, so each is stated once. */
163
172
  flattenedTools = new Set();
164
173
  constructor(options) {
165
174
  super();
166
175
  this.options = options;
167
- this.seedCuratedFastVariants();
168
176
  }
169
177
  /**
170
- * Record the fast variants the curated catalog already names as aliases.
178
+ * Adopt the rows of one live listing as this route's catalog.
171
179
  *
172
- * The alias is the recognition: `kimi-for-coding-highspeed` is stated as an accepted
173
- * id of `kimi-for-coding`, so the route can offer the quick-mode button without
174
- * waiting for a live list that may never name it. A row a live catalog already paired
175
- * keeps that pairing, so this only fills the gaps.
180
+ * Everything the picker shows is derived here from the endpoint's own answer: the
181
+ * rows that become picker entries, the fast variant that pairs with each row, and
182
+ * the image model that is called by an agent rather than chosen. Nothing about
183
+ * which models exist, or what each one is, is decided by this build.
184
+ *
185
+ * Fast variants ride with their base row instead of becoming rows of their own: they
186
+ * share the row's visibility switch and are reached by the composer's quick-mode
187
+ * button. A variant is recognized by its own name — the provider's fast word appended
188
+ * to a listed model's id — so no curated alias map is needed to pair them.
189
+ *
190
+ * @param rows - decoded rows of the endpoint's listing, in the endpoint's order.
191
+ * @returns the entries to publish, in picker order.
176
192
  */
177
- seedCuratedFastVariants() {
178
- for (const entry of KIMI_MODELS) {
179
- for (const alias of entry.aliases ?? []) {
180
- // Every alias of one row maps back to that row, so a variant whose own prefix
181
- // names another alias (`kimi-k2.7-code-highspeed`) still pairs with the row.
182
- this.fastVariants.note(alias, (base) => this.entryFor(base)?.id ?? base);
183
- }
193
+ adoptListing(rows) {
194
+ const facts = rows
195
+ .map((row) => liveModelFacts(row))
196
+ .filter((fact) => fact !== undefined);
197
+ // Image models are never picker rows: they are agent-called, always on.
198
+ this.imageModel = facts.find((fact) => isImageModel(fact.id))?.id;
199
+ this.fastVariants.clear();
200
+ for (const fact of facts)
201
+ this.fastVariants.noteListed(fact.id);
202
+ for (const fact of facts) {
203
+ const base = this.fastVariants.pair(fact.id);
204
+ this.fastVariants.note(fact.id, () => base ?? baseModelOf(fact.id));
205
+ }
206
+ // Fold a variant onto its row and drop the ids that are not chat models, keeping the
207
+ // endpoint's order. A row's own facts describe the row; a variant's describe the
208
+ // variant, so a row that is also listed is never overwritten by its variant.
209
+ const byId = new Map();
210
+ for (const fact of facts) {
211
+ if (NON_CHAT_ID.test(fact.id))
212
+ continue;
213
+ const id = isFastVariant(fact.id) ? this.fastVariants.pair(fact.id) ?? baseModelOf(fact.id) : fact.id;
214
+ if (id === fact.id || !byId.has(id))
215
+ byId.set(id, { ...fact, id });
216
+ }
217
+ return [...byId.values()].map((fact) => this.labelled(liveEntry(fact, [], KIMI_CONTEXT_WINDOW, 'Reported by the Kimi Code model list')));
218
+ }
219
+ /**
220
+ * Put this route's stable label on one entry, when the id has one.
221
+ *
222
+ * The listing states what the model *is*; the label states what the *id* is, which only
223
+ * this route knows (see {@link STABLE_MODEL_LABELS}).
224
+ *
225
+ * @param entry - the entry built from the listing.
226
+ * @returns the entry, relabelled when its id has a stable label.
227
+ */
228
+ labelled(entry) {
229
+ const stable = STABLE_MODEL_LABELS[entry.id];
230
+ return stable === undefined ? entry : { ...entry, name: stable.name, description: stable.description };
231
+ }
232
+ /**
233
+ * Read the cached listing, once, so the picker works without asking the endpoint.
234
+ *
235
+ * Called before the first catalog query. A read failure is a cache miss, never a
236
+ * failure: the route simply has no listing until one is fetched.
237
+ */
238
+ async hydrate() {
239
+ if (this.hydrated)
240
+ return;
241
+ this.hydrated = true;
242
+ if (this.live !== undefined || this.options.cachedListing === undefined)
243
+ return;
244
+ try {
245
+ const rows = await this.options.cachedListing();
246
+ if (Array.isArray(rows) && rows.length > 0)
247
+ this.live = this.adoptListing(rows);
248
+ }
249
+ catch (error) {
250
+ this.options.onWarn?.(`cached model catalog unavailable (${errorChain(error)})`);
184
251
  }
185
252
  }
186
253
  /** The fast pair one picker id belongs to, from either side. */
187
254
  fastPairOf(id) {
188
255
  return this.fastVariants.pairOf(id);
189
256
  }
257
+ /**
258
+ * Whether this route has read a model listing yet.
259
+ *
260
+ * A variant is derived from the listing, so until one has been read "this model has no
261
+ * variant" and "the listing has not arrived" look identical from outside; a caller that
262
+ * asks once (the composer's quick-mode button) needs to tell them apart.
263
+ */
264
+ hasListing() {
265
+ return this.live !== undefined;
266
+ }
190
267
  /**
191
268
  * Tool schemas in the shape Moonshot accepts: no `$ref`, and under its size
192
269
  * ceiling. A declaration too large to send keeps its arguments, and that is
@@ -219,6 +296,7 @@ export class KimiAdapter extends LlmAdapter {
219
296
  // must not appear in the picker, and Auto must not try it.
220
297
  if (await this.options.tokens.session() === undefined)
221
298
  return [];
299
+ await this.hydrate();
222
300
  await this.syncUserModels();
223
301
  const hidden = await this.hidden();
224
302
  // One row per model, always under the model's own id: the host validates every GUI
@@ -226,8 +304,7 @@ export class KimiAdapter extends LlmAdapter {
226
304
  // picker with no way back to the standard model. Fast mode is the row's `fast`
227
305
  // reasoning rung instead (see `stream`).
228
306
  const imageless = this.catalog().filter((entry) => !isImageModel(entry.id));
229
- return resolveCatalog(imageless, [], hidden).entries.map((entry) => ({
230
- provider: KIMI_PROVIDER_ID,
307
+ return resolveCatalog(imageless, [], hidden).entries.map((entry) => ({ provider: KIMI_PROVIDER_ID,
231
308
  id: entry.id,
232
309
  name: entry.name,
233
310
  ...entry.description === undefined ? {} : { description: entry.description },
@@ -256,8 +333,12 @@ export class KimiAdapter extends LlmAdapter {
256
333
  }
257
334
  }
258
335
  async resolveModel(_provider, model) {
336
+ await this.hydrate();
259
337
  await this.syncUserModels();
260
- const entry = this.entryFor(model);
338
+ // A retired spelling answers with the model that replaced it, so a session that
339
+ // saved the old id shows the right name, window and ladder before it dispatches.
340
+ const repaired = this.repairModelId(model);
341
+ const entry = this.entryFor(repaired ?? model);
261
342
  if (entry === undefined) {
262
343
  // An unknown id is still dispatchable: the endpoint accepts the id it
263
344
  // reports, and a model added after this build should not be refused. It
@@ -277,8 +358,9 @@ export class KimiAdapter extends LlmAdapter {
277
358
  // picker shows the row under its own name and the mode as the effort.
278
359
  const fastWireId = this.fastVariants.variantOf(entry.id);
279
360
  const reasoning = reasoningInfo(entry, fastWireId);
280
- // An alias is just another spelling of the row, so it resolves onto the row's own id:
281
- // that is what keeps a selection dispatchable and the picker able to show it.
361
+ // The listing's own id is what resolves here — a renamed or retired spelling is
362
+ // reported as the model the endpoint now calls it — which is what keeps a selection
363
+ // dispatchable and the picker able to show it.
282
364
  return {
283
365
  provider: KIMI_PROVIDER_ID,
284
366
  id: entry.id,
@@ -294,10 +376,18 @@ export class KimiAdapter extends LlmAdapter {
294
376
  // Fast mode rides the reasoning channel: the rung is the variant's own fast word, not
295
377
  // a model id of its own, so the row the picker selected stays dispatchable and the user
296
378
  // can always select the standard model again. Only the wire id changes.
297
- const fastWireId = this.fastVariants.variantOf(options.model);
298
379
  const effort = String(options.reasoningEffort ?? '');
380
+ // A request that already names a fast variant keeps that wire id: it is a model id of
381
+ // its own rather than a spelling of the row. Every other id is sent exactly as the
382
+ // endpoint's listing names it, except a spelling the endpoint has retired, which goes
383
+ // out as the model that replaced it — a session that saved `k3[1m]` repairs itself on
384
+ // the next turn instead of failing with HTTP 401.
385
+ const selected = isFastVariant(options.model)
386
+ ? options.model
387
+ : this.repairModelId(options.model) ?? options.model;
388
+ const fastWireId = this.fastVariants.variantOf(selected);
299
389
  const fast = fastWireId !== undefined && effort === fastWordOf(fastWireId);
300
- const model = fast ? fastWireId : options.model;
390
+ const model = fast ? fastWireId : selected;
301
391
  const call = { ...options, model };
302
392
  // Kimi has no fast-variant effort; the variant already carries the behaviour.
303
393
  if (fast)
@@ -314,6 +404,12 @@ export class KimiAdapter extends LlmAdapter {
314
404
  onAccountSwitch: (from, to, reason) => {
315
405
  this.options.onWarn?.(`kimi: account ${from} allowance spent (${reason}); switching to ${to}`);
316
406
  },
407
+ // The endpoint names the model id to use when a request asks for one it has
408
+ // retired; remembering it repairs this session rather than failing the turn.
409
+ onModelRenamed: (from, to) => {
410
+ this.renamed.set(from, to);
411
+ this.options.onWarn?.(`kimi: model ${from} is retired; using ${to}`);
412
+ },
317
413
  ...this.options.onCredentialRejected === undefined
318
414
  ? {}
319
415
  : { onCredentialRejected: this.options.onCredentialRejected },
@@ -339,36 +435,48 @@ export class KimiAdapter extends LlmAdapter {
339
435
  return [...this.shippedCatalog(), ...this.addedEntries()];
340
436
  }
341
437
  /**
342
- * The catalog to present: the shipped entries in their curated order and with
343
- * their curated metadata, then any configured extra id, then any live id this
344
- * build does not know.
438
+ * The catalog to present: the models the endpoint's listing named, in its own order,
439
+ * each with the metadata that listing stated, then the configured extras.
345
440
  *
346
- * The live list never removes a shipped entry: the endpoint serves variants
347
- * such as `k3[1m]` that its own `/models` listing omits, so absence there is
348
- * not evidence of retirement.
441
+ * There is no shipped roster to fall back to. A listing is read once and cached (see
442
+ * {@link hydrate} and {@link fetchCatalog}), and that cached table is what the picker
443
+ * shows until the next read, so a model released, renamed or retired server-side is
444
+ * reflected without a plugin update or a request per refresh.
349
445
  */
350
446
  shippedCatalog() {
351
- const extra = [];
352
- const seen = new Set();
353
- const append = (id, description) => {
354
- if (seen.has(id) || this.entryFor(id) !== undefined)
355
- return;
356
- seen.add(id);
357
- extra.push(fallbackEntry(id, description, KIMI_CONTEXT_WINDOW));
358
- };
359
- for (const id of this.options.extraModels ?? [])
360
- append(id, 'Extra model from the plugin configuration');
361
- for (const id of this.discovered ?? [])
362
- append(id, 'Reported by the Kimi Code model list');
363
- return extra.length === 0 ? KIMI_MODELS : [...KIMI_MODELS, ...extra];
364
- }
365
- /** Whether one shipped entry covers a live model id. */
366
- entryMatches(entry, id) {
367
- return entry.id === id || entry.aliases?.includes(id) === true;
447
+ return composeCatalog({
448
+ curated: [],
449
+ ...this.live === undefined ? {} : { live: this.live },
450
+ ...this.options.extraModels === undefined ? {} : { extra: this.options.extraModels },
451
+ defaultModel: this.options.defaultModel ?? KIMI_DEFAULT_MODEL,
452
+ fallbackContextWindow: KIMI_CONTEXT_WINDOW,
453
+ extraDescription: 'Extra model from the plugin configuration',
454
+ });
368
455
  }
369
- /** Resolve a requested model id onto its catalog entry. */
456
+ /** Resolve a requested model id onto the entry the endpoint's listing described. */
370
457
  entryFor(model) {
371
- return findEntry(KIMI_MODELS, model);
458
+ return findEntry(this.live ?? [], model);
459
+ }
460
+ /**
461
+ * The model one wire id names, when the endpoint has retired the spelling.
462
+ *
463
+ * Consulted only for an id the current listing does not serve, so an id the endpoint
464
+ * itself reports is always dispatched exactly as reported.
465
+ *
466
+ * @param model - the requested model id.
467
+ * @returns the current spelling, or undefined when the id is not a known retirement.
468
+ */
469
+ repairModelId(model) {
470
+ if (this.entryFor(model) !== undefined)
471
+ return undefined;
472
+ // A fast variant is a wire id rather than a row: a session that saved one belongs on
473
+ // the row that owns it, where the quick-mode rung is what switches the variant on.
474
+ const pair = this.fastVariants.pairOf(model);
475
+ if (pair !== undefined)
476
+ return pair.baseId;
477
+ // Prefer what this session was actually told over the table this build shipped: the
478
+ // endpoint's own answer is newer than any release.
479
+ return this.renamed.get(model) ?? RETIRED_MODEL_IDS[model];
372
480
  }
373
481
  /** Re-read the ids the user added by hand. */
374
482
  async syncUserModels() {
@@ -396,11 +504,22 @@ export class KimiAdapter extends LlmAdapter {
396
504
  .filter((id) => !known.has(id))
397
505
  .map((id) => fallbackEntry(id, 'Added by hand in Settings', KIMI_CONTEXT_WINDOW));
398
506
  }
399
- /** Record the live catalog for the picker; failures keep the shipped list. */
507
+ /**
508
+ * Adopt the ids a caller reports as the served set, without a listing.
509
+ *
510
+ * {@link fetchCatalog} already derived the entries from the rows it read, and the
511
+ * controller reports those same ids back here; the entries it recorded are kept. A
512
+ * caller that only has ids (a test, another discovery path) gets conservative entries
513
+ * for them.
514
+ *
515
+ * @param ids - model ids the caller reports.
516
+ */
400
517
  discover(ids) {
401
- this.discovered = ids;
518
+ const known = new Map((this.live ?? []).map((entry) => [entry.id, entry]));
519
+ this.live = ids.map((id) => known.get(id)
520
+ ?? this.labelled(liveEntry({ id }, [], KIMI_CONTEXT_WINDOW, 'Reported by the Kimi Code model list')));
402
521
  }
403
- /** The curated catalog, for the Settings page's model list. */
522
+ /** The catalog, for the Settings page's model list. */
404
523
  catalogEntries() {
405
524
  return this.catalog();
406
525
  }
@@ -476,7 +595,20 @@ export class KimiAdapter extends LlmAdapter {
476
595
  }
477
596
  };
478
597
  }
479
- /** Read the live `/models` catalog, or undefined when unavailable. */
598
+ /**
599
+ * Read the endpoint's model listing and adopt it as this route's catalog.
600
+ *
601
+ * This is the one place a model table is produced, and it is the endpoint's own
602
+ * answer: what the listing names is what the picker offers, with the display name,
603
+ * context window, modalities and reasoning ladder the listing states. The rows are
604
+ * also handed to {@link KimiAdapterOptions.saveListing}, so the next session starts
605
+ * from this table instead of asking again — the Settings "refresh models" action is
606
+ * what re-reads it.
607
+ *
608
+ * @param accessToken - bearer for the account.
609
+ * @param signal - optional cancellation.
610
+ * @returns the model ids now served, or undefined when the listing is unavailable.
611
+ */
480
612
  async fetchCatalog(accessToken, signal) {
481
613
  if (!this.options.discovery)
482
614
  return undefined;
@@ -488,45 +620,34 @@ export class KimiAdapter extends LlmAdapter {
488
620
  });
489
621
  if (!response.ok) {
490
622
  if (!isMissingOrInvalidCredential(await httpLlmError(response, 'Kimi models'))) {
491
- this.options.onWarn?.(`Kimi model discovery failed (HTTP ${response.status}); using the built-in catalog`);
623
+ this.options.onWarn?.(`Kimi model discovery failed (HTTP ${response.status}); keeping the last catalog`);
492
624
  }
493
625
  return undefined;
494
626
  }
495
627
  const payload = await response.json();
496
- const allIds = (payload.data ?? [])
497
- .map((entry) => entry.id)
498
- .filter((id) => typeof id === 'string' && id.length > 0);
499
- // Image models are never picker rows: they are agent-called, always on.
500
- this.imageModel = allIds.find((id) => isImageModel(id));
501
- // Fast/lightspeed variants ride with their base row: they share the row's
502
- // visibility switch and are reached by the composer's quick-mode button, not a
503
- // separate row. The live list is the authority on what this account serves, so
504
- // it is recorded first, and each variant pairs with the longest listed prefix
505
- // of its own name — a provider that names its variants after the base model
506
- // pairs them with no curated map at all.
507
- this.fastVariants.clear();
508
- for (const id of allIds) {
509
- this.fastVariants.noteListed(id);
510
- }
511
- for (const id of allIds) {
512
- const base = this.fastVariants.pair(id);
513
- this.fastVariants.note(id, () => base ?? this.entryFor(baseModelOf(id))?.id ?? baseModelOf(id));
514
- }
515
- this.seedCuratedFastVariants();
516
- const ids = allIds
517
- .filter((id) => !NON_CHAT_ID.test(id))
518
- .map((id) => (isFastVariant(id) ? this.fastVariants.pair(id) ?? baseModelOf(id) : id))
519
- .filter((id, index, list) => list.indexOf(id) === index);
520
- if (ids.length === 0) {
521
- this.options.onWarn?.('Kimi model discovery returned an empty catalog; using the built-in catalog');
628
+ const rows = Array.isArray(payload.data) ? payload.data : [];
629
+ const entries = this.adoptListing(rows);
630
+ if (entries.length === 0) {
631
+ this.options.onWarn?.('Kimi model discovery returned an empty catalog; keeping the last catalog');
522
632
  return undefined;
523
633
  }
524
- return ids;
634
+ this.live = entries;
635
+ // The cached table is the endpoint's answer, kept so the next session does not ask
636
+ // again. A cache failure never fails the read that produced it.
637
+ if (this.options.saveListing !== undefined) {
638
+ try {
639
+ await this.options.saveListing(rows);
640
+ }
641
+ catch (error) {
642
+ this.options.onWarn?.(`model catalog cache write failed (${errorChain(error)})`);
643
+ }
644
+ }
645
+ return entries.map((entry) => entry.id);
525
646
  }
526
647
  catch (error) {
527
648
  if (signal?.aborted === true)
528
649
  return undefined;
529
- this.options.onWarn?.(`Kimi model discovery failed; using the built-in catalog (${errorChain(error)})`);
650
+ this.options.onWarn?.(`Kimi model discovery failed; keeping the last catalog (${errorChain(error)})`);
530
651
  return undefined;
531
652
  }
532
653
  }
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * The `mimo` provider route's wiring: credential store, Xiaomi QR/password
3
- * login, model catalog, and its Settings card facts.
3
+ * login, model catalog (MiMo's own listing, read once and cached), and its
4
+ * Settings card facts.
4
5
  *
5
6
  * @module dsh-plugin-subscription-oauth/providers/mimo-provider
6
7
  */
@@ -12,10 +13,22 @@ import type { AttachmentStore } from '@deepseek-ai/dsh-attachment';
12
13
  export interface MimoProviderOptions {
13
14
  /** Absolute path of `auth.json`. */
14
15
  credentialFile: string;
16
+ /** Consult MiMo's own live model listing. Defaults to on. */
17
+ discovery?: boolean;
15
18
  /** Abandon a stream after this much silence. */
16
19
  streamIdleTimeoutMs: number;
17
20
  /** Model the picker preselects. */
18
21
  defaultModel?: string;
22
+ /**
23
+ * MiMo's last live listing, from this plugin's cache.
24
+ *
25
+ * The cached table is what the picker shows before any request: the endpoint's own
26
+ * answer from the last read, so a release does not need a plugin update and a refresh
27
+ * does not need a request. Absent, the built-in roster is what the picker shows.
28
+ */
29
+ cachedListing?: () => Promise<unknown>;
30
+ /** Record what a live listing just produced, replacing the cached table. */
31
+ saveListing?: (payload: unknown) => Promise<void>;
19
32
  /** Shared model-visibility preferences. */
20
33
  preferences: ModelPreferences;
21
34
  /** Resolve the attachment service per request. */
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * The `mimo` provider route's wiring: credential store, Xiaomi QR/password
3
- * login, model catalog, and its Settings card facts.
3
+ * login, model catalog (MiMo's own listing, read once and cached), and its
4
+ * Settings card facts.
4
5
  *
5
6
  * @module dsh-plugin-subscription-oauth/providers/mimo-provider
6
7
  */
@@ -9,7 +10,7 @@ import { TokenStore } from '../auth/store.js';
9
10
  import { TokenManager } from '../auth/tokens.js';
10
11
  import { LOGIN_CANCELLED_MESSAGE } from '../auth/device-flow.js';
11
12
  import { finishMimoSignIn, mimoCredentialsFrom, readMimoUsage, refreshMimoSession, startMimoSignIn, waitMimoPoll, } from '../auth/mimo-flow.js';
12
- import { MimoAdapter, MIMO_CODE_URL, MIMO_CONTEXT_WINDOW, MIMO_DEFAULT_MODEL, MIMO_MODELS, MIMO_PROVIDER_ID, MIMO_PROVIDER_NAME, parseMimoSession, } from './mimo.js';
13
+ import { MimoAdapter, MIMO_CODE_URL, MIMO_CONTEXT_WINDOW, MIMO_DEFAULT_MODEL, MIMO_PROVIDER_ID, MIMO_PROVIDER_NAME, parseMimoSession, } from './mimo.js';
13
14
  import { ProviderController } from '../auth/provider-controller.js';
14
15
  import { proxiedFetch } from '../http.js';
15
16
  /** Wait, honouring cancellation. */
@@ -111,27 +112,37 @@ export function createMimoProvider(options) {
111
112
  }
112
113
  },
113
114
  });
115
+ const discovery = options.discovery ?? true;
114
116
  const adapter = new MimoAdapter({
117
+ discovery,
115
118
  streamIdleTimeoutMs: options.streamIdleTimeoutMs,
116
119
  tokens,
117
120
  ...options.onWarn === undefined ? {} : { onWarn: options.onWarn },
118
121
  ...options.resolveAttachments === undefined ? {} : { resolveAttachments: options.resolveAttachments },
119
122
  hiddenModels: () => options.preferences.hiddenIds(MIMO_PROVIDER_ID),
120
123
  userModels: () => options.preferences.extraIds(MIMO_PROVIDER_ID),
124
+ ...options.cachedListing === undefined ? {} : { cachedListing: options.cachedListing },
125
+ ...options.saveListing === undefined ? {} : { saveListing: options.saveListing },
121
126
  fetchFn,
122
127
  });
128
+ // Read the cached table now rather than at the first picker query: the Settings page
129
+ // reads the catalog synchronously, so a request-free start must already have it.
130
+ void adapter.hydrate().catch(() => undefined);
123
131
  const spec = {
124
132
  id: MIMO_PROVIDER_ID,
125
133
  displayName: MIMO_PROVIDER_NAME,
126
134
  codeUrl: MIMO_CODE_URL,
127
135
  credentialFile: options.credentialFile,
128
- // MiMo ships a fixed two-model catalog; there is no discovery endpoint.
129
- discovery: false,
136
+ // MiMo's own listing is best-effort: it may answer the public API's ids rather than
137
+ // the app roster's, and it may refuse the session credential. What the picker shows
138
+ // until (and unless) it answers is the built-in roster, so a failed read can never
139
+ // empty the picker.
140
+ discovery,
130
141
  defaultModel: options.defaultModel ?? MIMO_DEFAULT_MODEL,
131
142
  catalog: () => adapter.catalogEntries(),
132
143
  catalogOwn: () => adapter.ownCatalogEntries(),
133
144
  fallbackContextWindow: MIMO_CONTEXT_WINDOW,
134
- discover: () => Promise.resolve(undefined),
145
+ discover: (accessToken) => adapter.fetchCatalog(accessToken),
135
146
  usage: async (_accessToken, account) => {
136
147
  const held = await store.get(account);
137
148
  if (held === undefined)
@@ -152,7 +163,7 @@ export function createMimoProvider(options) {
152
163
  { key: 'provider', value: MIMO_PROVIDER_NAME },
153
164
  { key: 'credentialFile', value: options.credentialFile },
154
165
  ],
155
- applyDiscovery: () => { },
166
+ applyDiscovery: (ids) => { adapter.discover(ids); },
156
167
  };
157
168
  const controller = new ProviderController({
158
169
  spec,