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
@@ -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,16 @@ 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
+ * Spellings the endpoint has retired, and the model each one became.
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
+ * A repair table, not a roster: nothing here decides which models exist. Each entry
78
+ * is consulted only for an id the live listing does not itself serve, so a spelling
79
+ * the endpoint reintroduces is dispatched exactly as the endpoint names it. The
80
+ * endpoint's own 401 message (`Please set model id as \`k3\``) is the general case
81
+ * and needs no table at all; this only saves the first failed round trip for a
82
+ * spelling already known to be gone.
80
83
  */
81
- export const KIMI_MODELS = [
82
- {
83
- id: 'kimi-for-coding',
84
- 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',
91
- },
92
- {
93
- id: 'k3',
94
- name: 'Kimi K3',
95
- 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
- },
111
- {
112
- id: 'k3-256k',
113
- 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',
119
- },
120
- ];
84
+ const RETIRED_MODEL_IDS = { 'k3[1m]': 'k3' };
121
85
  /** One `reasoning.efforts` entry for a resolved model. */
122
86
  function effortInfo(effort) {
123
87
  const label = EFFORT_LABELS[effort] ?? { name: effort, description: '' };
@@ -145,42 +109,103 @@ function reasoningInfo(entry, fastWireId) {
145
109
  /** Kimi Code adapter serving the `kimi` provider route. */
146
110
  export class KimiAdapter extends LlmAdapter {
147
111
  options;
148
- /** Live catalog ids seen this session, used to accept renamed models. */
149
- discovered;
150
- /** Image-generation model id, when discovery reported one. */
112
+ /**
113
+ * The catalog this route serves: entries derived from the endpoint's own listing,
114
+ * either the one just read or the one the cache kept from the last read.
115
+ *
116
+ * Undefined means neither source has produced a listing yet, so only the configured
117
+ * default model is offered until one does.
118
+ */
119
+ live;
120
+ /** Whether the cached listing has been read; it is read at most once per session. */
121
+ hydrated = false;
122
+ /** Image-generation model id, when the listing reported one. */
151
123
  imageModel;
152
124
  /**
153
125
  * Fast-variant wire id per picker row, for the composer's quick-mode button.
154
126
  *
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.
127
+ * Derived from the listing: `kimi-for-coding-highspeed` is a row of its own there,
128
+ * and pairs with `kimi-for-coding` because its name is that of a listed model plus
129
+ * the provider's own fast word.
158
130
  */
159
131
  fastVariants = new FastVariantIndex();
160
132
  /** Ids the user added by hand, cached so the sync catalog can name them. */
161
133
  userAddedIds = [];
134
+ /**
135
+ * Model ids the endpoint renamed during this session, and what each became.
136
+ *
137
+ * Learned from the endpoint's own refusal (`Please set model id as \`k3\``), so a
138
+ * spelling retired after this build shipped is repaired without a plugin update. The
139
+ * first turn pays one refused request; every later turn goes straight to the current id.
140
+ */
141
+ renamed = new Map();
162
142
  /** Tools already reported as flattened, so each is stated once. */
163
143
  flattenedTools = new Set();
164
144
  constructor(options) {
165
145
  super();
166
146
  this.options = options;
167
- this.seedCuratedFastVariants();
168
147
  }
169
148
  /**
170
- * Record the fast variants the curated catalog already names as aliases.
149
+ * Adopt the rows of one live listing as this route's catalog.
171
150
  *
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.
151
+ * Everything the picker shows is derived here from the endpoint's own answer: the
152
+ * rows that become picker entries, the fast variant that pairs with each row, and
153
+ * the image model that is called by an agent rather than chosen. Nothing about
154
+ * which models exist, or what each one is, is decided by this build.
155
+ *
156
+ * Fast variants ride with their base row instead of becoming rows of their own: they
157
+ * share the row's visibility switch and are reached by the composer's quick-mode
158
+ * button. A variant is recognized by its own name — the provider's fast word appended
159
+ * to a listed model's id — so no curated alias map is needed to pair them.
160
+ *
161
+ * @param rows - decoded rows of the endpoint's listing, in the endpoint's order.
162
+ * @returns the entries to publish, in picker order.
176
163
  */
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
- }
164
+ adoptListing(rows) {
165
+ const facts = rows
166
+ .map((row) => liveModelFacts(row))
167
+ .filter((fact) => fact !== undefined);
168
+ // Image models are never picker rows: they are agent-called, always on.
169
+ this.imageModel = facts.find((fact) => isImageModel(fact.id))?.id;
170
+ this.fastVariants.clear();
171
+ for (const fact of facts)
172
+ this.fastVariants.noteListed(fact.id);
173
+ for (const fact of facts) {
174
+ const base = this.fastVariants.pair(fact.id);
175
+ this.fastVariants.note(fact.id, () => base ?? baseModelOf(fact.id));
176
+ }
177
+ // Fold a variant onto its row and drop the ids that are not chat models, keeping the
178
+ // endpoint's order. A row's own facts describe the row; a variant's describe the
179
+ // variant, so a row that is also listed is never overwritten by its variant.
180
+ const byId = new Map();
181
+ for (const fact of facts) {
182
+ if (NON_CHAT_ID.test(fact.id))
183
+ continue;
184
+ const id = isFastVariant(fact.id) ? this.fastVariants.pair(fact.id) ?? baseModelOf(fact.id) : fact.id;
185
+ if (id === fact.id || !byId.has(id))
186
+ byId.set(id, { ...fact, id });
187
+ }
188
+ return [...byId.values()].map((fact) => liveEntry(fact, [], KIMI_CONTEXT_WINDOW, 'Reported by the Kimi Code model list'));
189
+ }
190
+ /**
191
+ * Read the cached listing, once, so the picker works without asking the endpoint.
192
+ *
193
+ * Called before the first catalog query. A read failure is a cache miss, never a
194
+ * failure: the route simply has no listing until one is fetched.
195
+ */
196
+ async hydrate() {
197
+ if (this.hydrated)
198
+ return;
199
+ this.hydrated = true;
200
+ if (this.live !== undefined || this.options.cachedListing === undefined)
201
+ return;
202
+ try {
203
+ const rows = await this.options.cachedListing();
204
+ if (Array.isArray(rows) && rows.length > 0)
205
+ this.live = this.adoptListing(rows);
206
+ }
207
+ catch (error) {
208
+ this.options.onWarn?.(`cached model catalog unavailable (${errorChain(error)})`);
184
209
  }
185
210
  }
186
211
  /** The fast pair one picker id belongs to, from either side. */
@@ -219,6 +244,7 @@ export class KimiAdapter extends LlmAdapter {
219
244
  // must not appear in the picker, and Auto must not try it.
220
245
  if (await this.options.tokens.session() === undefined)
221
246
  return [];
247
+ await this.hydrate();
222
248
  await this.syncUserModels();
223
249
  const hidden = await this.hidden();
224
250
  // One row per model, always under the model's own id: the host validates every GUI
@@ -226,8 +252,7 @@ export class KimiAdapter extends LlmAdapter {
226
252
  // picker with no way back to the standard model. Fast mode is the row's `fast`
227
253
  // reasoning rung instead (see `stream`).
228
254
  const imageless = this.catalog().filter((entry) => !isImageModel(entry.id));
229
- return resolveCatalog(imageless, [], hidden).entries.map((entry) => ({
230
- provider: KIMI_PROVIDER_ID,
255
+ return resolveCatalog(imageless, [], hidden).entries.map((entry) => ({ provider: KIMI_PROVIDER_ID,
231
256
  id: entry.id,
232
257
  name: entry.name,
233
258
  ...entry.description === undefined ? {} : { description: entry.description },
@@ -256,8 +281,12 @@ export class KimiAdapter extends LlmAdapter {
256
281
  }
257
282
  }
258
283
  async resolveModel(_provider, model) {
284
+ await this.hydrate();
259
285
  await this.syncUserModels();
260
- const entry = this.entryFor(model);
286
+ // A retired spelling answers with the model that replaced it, so a session that
287
+ // saved the old id shows the right name, window and ladder before it dispatches.
288
+ const repaired = this.repairModelId(model);
289
+ const entry = this.entryFor(repaired ?? model);
261
290
  if (entry === undefined) {
262
291
  // An unknown id is still dispatchable: the endpoint accepts the id it
263
292
  // reports, and a model added after this build should not be refused. It
@@ -277,8 +306,9 @@ export class KimiAdapter extends LlmAdapter {
277
306
  // picker shows the row under its own name and the mode as the effort.
278
307
  const fastWireId = this.fastVariants.variantOf(entry.id);
279
308
  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.
309
+ // The listing's own id is what resolves here — a renamed or retired spelling is
310
+ // reported as the model the endpoint now calls it — which is what keeps a selection
311
+ // dispatchable and the picker able to show it.
282
312
  return {
283
313
  provider: KIMI_PROVIDER_ID,
284
314
  id: entry.id,
@@ -294,10 +324,18 @@ export class KimiAdapter extends LlmAdapter {
294
324
  // Fast mode rides the reasoning channel: the rung is the variant's own fast word, not
295
325
  // a model id of its own, so the row the picker selected stays dispatchable and the user
296
326
  // can always select the standard model again. Only the wire id changes.
297
- const fastWireId = this.fastVariants.variantOf(options.model);
298
327
  const effort = String(options.reasoningEffort ?? '');
328
+ // A request that already names a fast variant keeps that wire id: it is a model id of
329
+ // its own rather than a spelling of the row. Every other id is sent exactly as the
330
+ // endpoint's listing names it, except a spelling the endpoint has retired, which goes
331
+ // out as the model that replaced it — a session that saved `k3[1m]` repairs itself on
332
+ // the next turn instead of failing with HTTP 401.
333
+ const selected = isFastVariant(options.model)
334
+ ? options.model
335
+ : this.repairModelId(options.model) ?? options.model;
336
+ const fastWireId = this.fastVariants.variantOf(selected);
299
337
  const fast = fastWireId !== undefined && effort === fastWordOf(fastWireId);
300
- const model = fast ? fastWireId : options.model;
338
+ const model = fast ? fastWireId : selected;
301
339
  const call = { ...options, model };
302
340
  // Kimi has no fast-variant effort; the variant already carries the behaviour.
303
341
  if (fast)
@@ -314,6 +352,12 @@ export class KimiAdapter extends LlmAdapter {
314
352
  onAccountSwitch: (from, to, reason) => {
315
353
  this.options.onWarn?.(`kimi: account ${from} allowance spent (${reason}); switching to ${to}`);
316
354
  },
355
+ // The endpoint names the model id to use when a request asks for one it has
356
+ // retired; remembering it repairs this session rather than failing the turn.
357
+ onModelRenamed: (from, to) => {
358
+ this.renamed.set(from, to);
359
+ this.options.onWarn?.(`kimi: model ${from} is retired; using ${to}`);
360
+ },
317
361
  ...this.options.onCredentialRejected === undefined
318
362
  ? {}
319
363
  : { onCredentialRejected: this.options.onCredentialRejected },
@@ -339,36 +383,48 @@ export class KimiAdapter extends LlmAdapter {
339
383
  return [...this.shippedCatalog(), ...this.addedEntries()];
340
384
  }
341
385
  /**
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.
386
+ * The catalog to present: the models the endpoint's listing named, in its own order,
387
+ * each with the metadata that listing stated, then the configured extras.
345
388
  *
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.
389
+ * There is no shipped roster to fall back to. A listing is read once and cached (see
390
+ * {@link hydrate} and {@link fetchCatalog}), and that cached table is what the picker
391
+ * shows until the next read, so a model released, renamed or retired server-side is
392
+ * reflected without a plugin update or a request per refresh.
349
393
  */
350
394
  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;
395
+ return composeCatalog({
396
+ curated: [],
397
+ ...this.live === undefined ? {} : { live: this.live },
398
+ ...this.options.extraModels === undefined ? {} : { extra: this.options.extraModels },
399
+ defaultModel: this.options.defaultModel ?? KIMI_DEFAULT_MODEL,
400
+ fallbackContextWindow: KIMI_CONTEXT_WINDOW,
401
+ extraDescription: 'Extra model from the plugin configuration',
402
+ });
368
403
  }
369
- /** Resolve a requested model id onto its catalog entry. */
404
+ /** Resolve a requested model id onto the entry the endpoint's listing described. */
370
405
  entryFor(model) {
371
- return findEntry(KIMI_MODELS, model);
406
+ return findEntry(this.live ?? [], model);
407
+ }
408
+ /**
409
+ * The model one wire id names, when the endpoint has retired the spelling.
410
+ *
411
+ * Consulted only for an id the current listing does not serve, so an id the endpoint
412
+ * itself reports is always dispatched exactly as reported.
413
+ *
414
+ * @param model - the requested model id.
415
+ * @returns the current spelling, or undefined when the id is not a known retirement.
416
+ */
417
+ repairModelId(model) {
418
+ if (this.entryFor(model) !== undefined)
419
+ return undefined;
420
+ // A fast variant is a wire id rather than a row: a session that saved one belongs on
421
+ // the row that owns it, where the quick-mode rung is what switches the variant on.
422
+ const pair = this.fastVariants.pairOf(model);
423
+ if (pair !== undefined)
424
+ return pair.baseId;
425
+ // Prefer what this session was actually told over the table this build shipped: the
426
+ // endpoint's own answer is newer than any release.
427
+ return this.renamed.get(model) ?? RETIRED_MODEL_IDS[model];
372
428
  }
373
429
  /** Re-read the ids the user added by hand. */
374
430
  async syncUserModels() {
@@ -396,11 +452,22 @@ export class KimiAdapter extends LlmAdapter {
396
452
  .filter((id) => !known.has(id))
397
453
  .map((id) => fallbackEntry(id, 'Added by hand in Settings', KIMI_CONTEXT_WINDOW));
398
454
  }
399
- /** Record the live catalog for the picker; failures keep the shipped list. */
455
+ /**
456
+ * Adopt the ids a caller reports as the served set, without a listing.
457
+ *
458
+ * {@link fetchCatalog} already derived the entries from the rows it read, and the
459
+ * controller reports those same ids back here; the entries it recorded are kept. A
460
+ * caller that only has ids (a test, another discovery path) gets conservative entries
461
+ * for them.
462
+ *
463
+ * @param ids - model ids the caller reports.
464
+ */
400
465
  discover(ids) {
401
- this.discovered = ids;
466
+ const known = new Map((this.live ?? []).map((entry) => [entry.id, entry]));
467
+ this.live = ids.map((id) => known.get(id)
468
+ ?? liveEntry({ id }, [], KIMI_CONTEXT_WINDOW, 'Reported by the Kimi Code model list'));
402
469
  }
403
- /** The curated catalog, for the Settings page's model list. */
470
+ /** The catalog, for the Settings page's model list. */
404
471
  catalogEntries() {
405
472
  return this.catalog();
406
473
  }
@@ -476,7 +543,20 @@ export class KimiAdapter extends LlmAdapter {
476
543
  }
477
544
  };
478
545
  }
479
- /** Read the live `/models` catalog, or undefined when unavailable. */
546
+ /**
547
+ * Read the endpoint's model listing and adopt it as this route's catalog.
548
+ *
549
+ * This is the one place a model table is produced, and it is the endpoint's own
550
+ * answer: what the listing names is what the picker offers, with the display name,
551
+ * context window, modalities and reasoning ladder the listing states. The rows are
552
+ * also handed to {@link KimiAdapterOptions.saveListing}, so the next session starts
553
+ * from this table instead of asking again — the Settings "refresh models" action is
554
+ * what re-reads it.
555
+ *
556
+ * @param accessToken - bearer for the account.
557
+ * @param signal - optional cancellation.
558
+ * @returns the model ids now served, or undefined when the listing is unavailable.
559
+ */
480
560
  async fetchCatalog(accessToken, signal) {
481
561
  if (!this.options.discovery)
482
562
  return undefined;
@@ -488,45 +568,34 @@ export class KimiAdapter extends LlmAdapter {
488
568
  });
489
569
  if (!response.ok) {
490
570
  if (!isMissingOrInvalidCredential(await httpLlmError(response, 'Kimi models'))) {
491
- this.options.onWarn?.(`Kimi model discovery failed (HTTP ${response.status}); using the built-in catalog`);
571
+ this.options.onWarn?.(`Kimi model discovery failed (HTTP ${response.status}); keeping the last catalog`);
492
572
  }
493
573
  return undefined;
494
574
  }
495
575
  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');
576
+ const rows = Array.isArray(payload.data) ? payload.data : [];
577
+ const entries = this.adoptListing(rows);
578
+ if (entries.length === 0) {
579
+ this.options.onWarn?.('Kimi model discovery returned an empty catalog; keeping the last catalog');
522
580
  return undefined;
523
581
  }
524
- return ids;
582
+ this.live = entries;
583
+ // The cached table is the endpoint's answer, kept so the next session does not ask
584
+ // again. A cache failure never fails the read that produced it.
585
+ if (this.options.saveListing !== undefined) {
586
+ try {
587
+ await this.options.saveListing(rows);
588
+ }
589
+ catch (error) {
590
+ this.options.onWarn?.(`model catalog cache write failed (${errorChain(error)})`);
591
+ }
592
+ }
593
+ return entries.map((entry) => entry.id);
525
594
  }
526
595
  catch (error) {
527
596
  if (signal?.aborted === true)
528
597
  return undefined;
529
- this.options.onWarn?.(`Kimi model discovery failed; using the built-in catalog (${errorChain(error)})`);
598
+ this.options.onWarn?.(`Kimi model discovery failed; keeping the last catalog (${errorChain(error)})`);
530
599
  return undefined;
531
600
  }
532
601
  }
@@ -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,