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
@@ -16,7 +16,7 @@ import { TokenStore, finiteInstant, nonEmpty } from '../auth/store.js';
16
16
  import { TokenManager } from '../auth/tokens.js';
17
17
  import { ProviderController } from '../auth/provider-controller.js';
18
18
  import { proxiedFetch } from '../http.js';
19
- import { fallbackEntry, findEntry, resolveCatalog } from './catalog.js';
19
+ import { composeCatalog, fallbackEntry, findEntry, liveEntry, liveModelFacts, resolveCatalog } from './catalog.js';
20
20
  import { isMissingOrInvalidCredential } from './common.js';
21
21
  import { streamProviderResponses, accountPoolHooks } from './responses-stream.js';
22
22
  import { readGrokUsage } from './usage.js';
@@ -66,11 +66,14 @@ const EFFORT_LABELS = {
66
66
  [FAST_RUNG]: { name: 'Fast', description: 'xAI fast variant of this model' },
67
67
  };
68
68
  /**
69
- * Curated catalog entries.
69
+ * The cold-start seed: what this build knows about xAI's models before either of the
70
+ * account's own listings has been read.
70
71
  *
71
- * Seeds only what the provider's own documentation names; everything else the
72
- * account is entitled to arrives through live discovery, so a new model does not
73
- * need a plugin release.
72
+ * A seed, never a roster. It decides which ids exist only until a listing has been read
73
+ * — the cached one at startup, or a fresh one on "refresh models" — and afterwards it
74
+ * describes only the fields those listings left silent. A fact the endpoint states,
75
+ * a context window included, always wins over anything here, so a model released,
76
+ * renamed or retired server-side reaches the picker without a plugin release.
74
77
  */
75
78
  export const GROK_MODELS = [
76
79
  {
@@ -276,6 +279,75 @@ export async function refreshGrokToken(input) {
276
279
  }
277
280
  return parseGrokTokenPayload(payload, input.refreshToken);
278
281
  }
282
+ /** Description the picker shows for a model only the endpoint's listing describes. */
283
+ const GROK_LIVE_DESCRIPTION = 'Reported by the xAI model list';
284
+ /**
285
+ * Split one cached or fresh payload into the two listings it holds.
286
+ *
287
+ * The payload is this route's own object, so its shape is validated rather than trusted:
288
+ * a document written by an older build, or corrupted on disk, is a cache miss and never
289
+ * a crash on the way to the picker.
290
+ *
291
+ * @param payload - the cached or freshly assembled `{ public, cli }` payload.
292
+ * @returns the two row lists, or undefined when the payload is not this route's.
293
+ */
294
+ function listingOf(payload) {
295
+ if (typeof payload !== 'object' || payload === null || Array.isArray(payload))
296
+ return undefined;
297
+ const record = payload;
298
+ const publicRows = record['public'];
299
+ if (!Array.isArray(publicRows))
300
+ return undefined;
301
+ const cliRows = record['cli'];
302
+ return { public: publicRows, cli: Array.isArray(cliRows) ? cliRows : [] };
303
+ }
304
+ /**
305
+ * Read the capability metadata and the ids one CLI catalog answer named.
306
+ *
307
+ * The shape is xAI's own rather than the OpenAI-compatible one the shared row reader
308
+ * handles: a context window under `context_window`, and a ladder under
309
+ * `reasoning_efforts[].value` gated by `supports_reasoning_effort`. A row that states
310
+ * nothing usable contributes only its id, because the id list is what folds a fast
311
+ * variant onto its base row.
312
+ *
313
+ * @param rows - decoded rows of the CLI catalog answer.
314
+ * @returns the metadata by model id, and every id the catalog named.
315
+ */
316
+ function cliListingOf(rows) {
317
+ const metadata = new Map();
318
+ const ids = [];
319
+ for (const row of rows) {
320
+ if (typeof row !== 'object' || row === null || Array.isArray(row))
321
+ continue;
322
+ const entry = row;
323
+ const rawId = entry['id'];
324
+ const id = typeof rawId === 'string' && rawId.length > 0 ? rawId : undefined;
325
+ if (id === undefined)
326
+ continue;
327
+ ids.push(id);
328
+ const rawContext = entry['context_window'];
329
+ const contextWindow = typeof rawContext === 'number' && Number.isFinite(rawContext) ? rawContext : undefined;
330
+ const supportsEffort = entry['supports_reasoning_effort'] === true;
331
+ const rawEfforts = entry['reasoning_efforts'];
332
+ const efforts = supportsEffort && Array.isArray(rawEfforts)
333
+ ? rawEfforts
334
+ .map((effort) => (typeof effort === 'object' && effort !== null
335
+ ? effort['value']
336
+ : undefined))
337
+ .filter((value) => typeof value === 'string' && value.length > 0)
338
+ : [];
339
+ const requested = entry['reasoning_effort'];
340
+ const defaultEffort = supportsEffort && typeof requested === 'string' && efforts.includes(requested)
341
+ ? requested
342
+ : undefined;
343
+ metadata.set(id, {
344
+ ...contextWindow === undefined ? {} : { contextWindow },
345
+ ...efforts.length === 0 ? {} : { reasoningEfforts: efforts },
346
+ ...defaultEffort === undefined ? {} : { defaultEffort },
347
+ });
348
+ }
349
+ return { metadata, ids };
350
+ }
279
351
  /** Model ids that are clearly not chat models. */
280
352
  const NON_CHAT_ID = /(?:^|[-_/])(?:image|video|imagine|embed|embedding|whisper|tts|vision-only)/i;
281
353
  /** Model ids that are image-generation models, matched per provider's naming. */
@@ -438,13 +510,23 @@ export function stripContext(id) {
438
510
  /** Grok wire adapter: one instance serves the `grok` provider route. */
439
511
  export class GrokAdapter extends LlmAdapter {
440
512
  options;
441
- /** Live model ids seen this session. */
442
- discovered;
513
+ /**
514
+ * The catalog this route serves: entries derived from the endpoint's own two
515
+ * listings, either the pair just read or the pair the cache kept from the last read.
516
+ *
517
+ * Undefined means neither source has produced a listing yet, so the cold-start seed
518
+ * is all this build can honestly offer.
519
+ */
520
+ live;
521
+ /** Whether the cached listing has been read; it is read at most once per session. */
522
+ hydrated = false;
443
523
  /** Capability metadata from the CLI catalog, keyed by model id. */
444
524
  metadata = new Map();
445
525
  /** Fast-variant wire id per base model, for the picker's quick-mode button. */
446
526
  fastVariants = new FastVariantIndex();
447
- /** Image-generation model id, when discovery reported one. */
527
+ /** The context variant one base row dispatches to, so `@500k` is never typed by hand. */
528
+ defaultContext = new Map();
529
+ /** Image-generation model id, when the listing reported one. */
448
530
  imageModel;
449
531
  /** Ids the user added by hand, cached so the sync catalog can name them. */
450
532
  userAddedIds = [];
@@ -452,6 +534,146 @@ export class GrokAdapter extends LlmAdapter {
452
534
  super();
453
535
  this.options = options;
454
536
  }
537
+ /**
538
+ * Adopt the two listings of one live read as this route's catalog.
539
+ *
540
+ * Everything the picker shows is derived here from the endpoint's own answers: the
541
+ * public `/models` rows decide membership and state each model's display name, window
542
+ * and modalities, and the account's CLI catalog states the reasoning ladder and names
543
+ * the fast variants — the one thing the public list omits.
544
+ *
545
+ * A fast variant or an `@Nk` context variant rides with its base row instead of becoming
546
+ * a row of its own: the variant is a wire id the composer dispatches, never a picker
547
+ * row, so the user always has a way back to the standard model.
548
+ *
549
+ * @param payload - the cached or freshly assembled `{ public, cli }` payload.
550
+ * @returns the entries to publish, in the endpoint's own order; undefined when the
551
+ * payload names no model at all, so the caller keeps whatever it already had.
552
+ */
553
+ adoptListing(payload) {
554
+ const listing = listingOf(payload);
555
+ if (listing === undefined)
556
+ return undefined;
557
+ const facts = listing.public
558
+ .map((row) => liveModelFacts(row))
559
+ .filter((fact) => fact !== undefined);
560
+ const cli = cliListingOf(listing.cli);
561
+ const chat = facts.filter((fact) => !NON_CHAT_ID.test(fact.id) && !isImageModel(fact.id));
562
+ // Membership comes from the public list, so a payload that named none of it is a miss
563
+ // rather than an empty picker.
564
+ if (chat.length === 0)
565
+ return undefined;
566
+ // One payload is the whole truth, so every table derived from an earlier read is
567
+ // replaced rather than merged: a model the endpoint has retired cannot survive in
568
+ // state left behind by a read that no longer names it.
569
+ this.metadata.clear();
570
+ this.fastVariants.clear();
571
+ this.defaultContext.clear();
572
+ for (const [id, meta] of cli.metadata)
573
+ this.metadata.set(id, meta);
574
+ // Image models are never picker rows: an agent calls them, always on.
575
+ this.imageModel = [...facts.map((fact) => fact.id), ...cli.ids].find((id) => isImageModel(id));
576
+ // Every id either listing named is what a variant may pair with; the pairing itself is
577
+ // the longest listed prefix of the variant's own name.
578
+ const wireIds = [
579
+ ...chat.map((fact) => fact.id),
580
+ ...cli.ids.filter((id) => !NON_CHAT_ID.test(id) && !isImageModel(id)),
581
+ ];
582
+ for (const id of wireIds)
583
+ this.fastVariants.noteListed(id);
584
+ this.noteWireVariants(chat.map((fact) => fact.id));
585
+ this.noteWireVariants(cli.ids);
586
+ // Fold each variant onto its row, keeping the endpoint's order. A row's own facts are
587
+ // its identity and win outright; a variant only fills what they omit, except a context
588
+ // variant, whose window is the one the row actually dispatches to.
589
+ const own = new Map();
590
+ const folds = new Map();
591
+ const windows = new Map();
592
+ const order = [];
593
+ for (const fact of chat) {
594
+ const contextVariant = contextOf(fact.id) !== undefined;
595
+ const rowId = isFastVariant(fact.id)
596
+ ? this.fastVariants.pair(fact.id) ?? baseModelOf(fact.id)
597
+ : contextVariant ? stripContext(fact.id) : fact.id;
598
+ if (rowId === '')
599
+ continue;
600
+ if (!own.has(rowId) && !folds.has(rowId))
601
+ order.push(rowId);
602
+ if (contextVariant)
603
+ windows.set(rowId, Math.max(windows.get(rowId) ?? 0, fact.contextWindow ?? 0));
604
+ if (rowId === fact.id) {
605
+ if (!own.has(rowId))
606
+ own.set(rowId, fact);
607
+ }
608
+ else {
609
+ folds.set(rowId, [...folds.get(rowId) ?? [], fact]);
610
+ }
611
+ }
612
+ return order.map((rowId) => {
613
+ const stated = own.get(rowId);
614
+ const variants = folds.get(rowId) ?? [];
615
+ const fill = (pick) => {
616
+ const ownValue = stated === undefined ? undefined : pick(stated);
617
+ if (ownValue !== undefined)
618
+ return ownValue;
619
+ for (const variant of variants) {
620
+ const value = pick(variant);
621
+ if (value !== undefined)
622
+ return value;
623
+ }
624
+ return undefined;
625
+ };
626
+ const widest = windows.get(rowId);
627
+ const statedWindow = stated?.contextWindow;
628
+ const contextWindow = widest === undefined || (statedWindow ?? 0) >= widest ? statedWindow : widest;
629
+ const inputModalities = fill((fact) => fact.inputModalities);
630
+ const reasoningEfforts = fill((fact) => fact.reasoningEfforts);
631
+ const defaultEffort = fill((fact) => fact.defaultEffort);
632
+ // The CLI catalog is a listing too, so its figures only fill what the public row
633
+ // left silent: a window or a ladder the endpoint already stated is never overridden,
634
+ // and the shipped seed never overrides either of them.
635
+ const meta = [rowId, ...variants.map((variant) => variant.id)]
636
+ .map((id) => this.metadata.get(id))
637
+ .find((entry) => entry !== undefined);
638
+ const context = contextWindow ?? meta?.contextWindow;
639
+ const efforts = reasoningEfforts ?? meta?.reasoningEfforts;
640
+ const metaDefault = meta?.defaultEffort;
641
+ // A default the ladder does not offer would leave the picker with a rung it cannot
642
+ // select, so it is stated only when the ladder that won actually contains it.
643
+ const fallback = defaultEffort
644
+ ?? (metaDefault === undefined || efforts !== undefined && !efforts.includes(metaDefault) ? undefined : metaDefault);
645
+ const described = {
646
+ ...stated ?? { id: rowId },
647
+ ...context === undefined ? {} : { contextWindow: context },
648
+ ...efforts === undefined ? {} : { reasoningEfforts: efforts },
649
+ ...fallback === undefined ? {} : { defaultEffort: fallback },
650
+ ...inputModalities === undefined ? {} : { inputModalities },
651
+ };
652
+ return liveEntry(described, GROK_MODELS, GROK_CONTEXT_WINDOW, GROK_LIVE_DESCRIPTION);
653
+ });
654
+ }
655
+ /**
656
+ * Read the cached listing, once, so the picker works without asking the endpoint.
657
+ *
658
+ * Called before the first catalog query, and fired at construction by the route's
659
+ * factory because the Settings page reads the catalog synchronously. A read failure is
660
+ * a cache miss, never a failure: the route simply has no listing until one is fetched.
661
+ */
662
+ async hydrate() {
663
+ if (this.hydrated)
664
+ return;
665
+ this.hydrated = true;
666
+ if (this.live !== undefined || this.options.cachedListing === undefined)
667
+ return;
668
+ try {
669
+ const entries = this.adoptListing(await this.options.cachedListing());
670
+ if (entries !== undefined)
671
+ this.live = entries;
672
+ }
673
+ catch (error) {
674
+ this.options.onWarn?.(`cached model catalog unavailable (${errorChain(error)})`);
675
+ }
676
+ }
455
677
  providerInfo(provider) {
456
678
  return { id: provider, name: GROK_PROVIDER_NAME };
457
679
  }
@@ -464,6 +686,7 @@ export class GrokAdapter extends LlmAdapter {
464
686
  // must not appear in the picker, and Auto must not try it.
465
687
  if (await this.options.tokens.session() === undefined)
466
688
  return [];
689
+ await this.hydrate();
467
690
  await this.syncUserModels();
468
691
  const hidden = await this.hidden();
469
692
  // One row per model, always under the model's own id: the host validates every GUI
@@ -491,14 +714,18 @@ export class GrokAdapter extends LlmAdapter {
491
714
  }
492
715
  }
493
716
  async resolveModel(_provider, model) {
717
+ await this.hydrate();
494
718
  await this.syncUserModels();
495
719
  // A fast variant is not a catalog row of its own: it answers with its base model's
496
- // metadata — the same 500k window, name and ladder the picker shows — while the id
720
+ // metadata — the same window, name and ladder the picker shows — while the id
497
721
  // stays the fast wire id the request must send.
498
722
  const pair = this.fastPairOf(model);
499
723
  const entry = findEntry(this.catalog(), model)
500
724
  ?? (pair === undefined ? undefined : findEntry(this.catalog(), pair.baseId));
501
725
  if (entry === undefined) {
726
+ // An unknown id is still dispatchable: the endpoint accepts the id it reports, and a
727
+ // model added after this build should not be refused. It gets the conservative
728
+ // defaults, described by the cold-start seed rather than by a guess.
502
729
  const template = findEntry(GROK_MODELS, 'grok-4.6');
503
730
  const reasoning = template === undefined ? undefined : reasoningInfo(template);
504
731
  return {
@@ -567,45 +794,25 @@ export class GrokAdapter extends LlmAdapter {
567
794
  return [...this.shippedCatalog(), ...this.addedEntries()];
568
795
  }
569
796
  /**
570
- * The catalog to present: curated seeds with their shipped metadata, then any
571
- * live id this build does not know, enriched from the CLI catalog when it
572
- * disclosed that model's context window or reasoning ladder.
797
+ * The catalog to present: the models both of the endpoint's listings named, in the
798
+ * public list's own order, each with the metadata the listings stated.
799
+ *
800
+ * There is no shipped roster to fall back to. The listings are read once and cached
801
+ * (see {@link hydrate} and {@link fetchCatalog}), and that cached table is what the
802
+ * picker shows until the next read, so a model released, renamed or retired
803
+ * server-side is reflected without a plugin update or a request per refresh.
804
+ *
805
+ * {@link GROK_MODELS} is only the cold-start seed: it decides membership while neither
806
+ * listing has been read, and afterwards describes the fields the listings left silent.
807
+ * A live fact, a context window included, always wins over it.
573
808
  */
574
809
  shippedCatalog() {
575
- const extra = [];
576
- const seen = new Set();
577
- for (const id of this.discovered ?? []) {
578
- if (findEntry(GROK_MODELS, id) !== undefined || seen.has(id))
579
- continue;
580
- seen.add(id);
581
- const meta = this.metadata.get(id);
582
- extra.push({
583
- id,
584
- name: id,
585
- description: 'Reported by the xAI model list',
586
- contextWindow: meta?.contextWindow ?? GROK_CONTEXT_WINDOW,
587
- inputModalities: ['text', 'image'],
588
- // A ladder is advertised only when the provider's own catalog gave one.
589
- ...meta?.reasoningEfforts === undefined ? {} : { reasoningEfforts: meta.reasoningEfforts },
590
- ...meta?.defaultEffort === undefined ? {} : { defaultEffort: meta.defaultEffort },
591
- });
592
- }
593
- // The seeds also pick up live metadata (reasoning ladder, default effort). The
594
- // curated context window is never narrowed by a live figure: the CLI catalog states
595
- // 256k for `grok-4.7`, which serves 500k, and a live read may only widen what this
596
- // build ships.
597
- const seeded = GROK_MODELS.map((entry) => {
598
- const meta = this.metadata.get(entry.id);
599
- if (meta === undefined)
600
- return entry;
601
- return {
602
- ...entry,
603
- contextWindow: Math.max(entry.contextWindow, meta.contextWindow ?? 0),
604
- ...meta.reasoningEfforts === undefined ? {} : { reasoningEfforts: meta.reasoningEfforts },
605
- ...meta.defaultEffort === undefined ? {} : { defaultEffort: meta.defaultEffort },
606
- };
810
+ return composeCatalog({
811
+ curated: GROK_MODELS,
812
+ ...this.live === undefined ? {} : { live: this.live },
813
+ fallbackContextWindow: GROK_CONTEXT_WINDOW,
814
+ liveDescription: GROK_LIVE_DESCRIPTION,
607
815
  });
608
- return [...seeded, ...extra];
609
816
  }
610
817
  /** Re-read the ids the user added by hand. */
611
818
  async syncUserModels() {
@@ -633,13 +840,21 @@ export class GrokAdapter extends LlmAdapter {
633
840
  .filter((id) => !known.has(id))
634
841
  .map((id) => fallbackEntry(id, 'Added by hand in Settings', GROK_CONTEXT_WINDOW));
635
842
  }
636
- /** Record the live catalog for the picker. */
637
- discover(ids, metadata) {
638
- this.discovered = ids;
639
- if (metadata === undefined)
640
- return;
641
- for (const [id, meta] of metadata)
642
- this.metadata.set(id, meta);
843
+ /**
844
+ * Adopt the ids a caller reports as the served set, without a listing.
845
+ *
846
+ * {@link fetchCatalog} already derived the entries from the rows it read, and the
847
+ * controller reports the union of every account's ids back here; the entries it
848
+ * recorded are kept, and an id no entry describes gets a conservative row described by
849
+ * the cold-start seed. An id the caller does not report is dropped: the reported set is
850
+ * the endpoint's whole answer.
851
+ *
852
+ * @param ids - model ids the caller reports.
853
+ */
854
+ discover(ids) {
855
+ const known = new Map((this.live ?? []).map((entry) => [entry.id, entry]));
856
+ this.live = ids.map((id) => known.get(id)
857
+ ?? liveEntry({ id }, GROK_MODELS, GROK_CONTEXT_WINDOW, GROK_LIVE_DESCRIPTION));
643
858
  }
644
859
  /** The fast/lightspeed variant wire id for a base model, when discovery reported one. */
645
860
  fastVariant(baseId) {
@@ -662,8 +877,6 @@ export class GrokAdapter extends LlmAdapter {
662
877
  return this.shippedCatalog();
663
878
  }
664
879
  /** Build and send one request for one model. */
665
- /** The default context variant per base model, set during discovery. */
666
- defaultContext = new Map();
667
880
  /** Resolve a model id to the wire id the endpoint accepts (largest context). */
668
881
  resolveModelId(model) {
669
882
  return this.defaultContext.get(model) ?? model;
@@ -726,9 +939,18 @@ export class GrokAdapter extends LlmAdapter {
726
939
  };
727
940
  }
728
941
  /**
729
- * Read the live model list, and the CLI catalog's capability metadata.
730
- * @param accessToken - bearer for the account's models.
731
- * @returns the model ids, or undefined when discovery is off or unavailable.
942
+ * Read both of the endpoint's listings and adopt them as this route's catalog.
943
+ *
944
+ * This is the one place a model table is produced, and it is the endpoint's own answer:
945
+ * what the public list names is what the picker offers, and the account's CLI catalog
946
+ * states the capabilities and names the fast variants that list omits. The rows are also
947
+ * handed to {@link GrokAdapterOptions.saveListing}, so the next session starts from this
948
+ * table instead of asking again — the Settings "refresh models" action is what re-reads
949
+ * it, and a picker query never does.
950
+ *
951
+ * @param accessToken - bearer for the account.
952
+ * @param signal - optional cancellation.
953
+ * @returns the model ids now served, or undefined when the listing is unavailable.
732
954
  */
733
955
  async fetchCatalog(accessToken, signal) {
734
956
  if (!this.options.discovery)
@@ -743,56 +965,36 @@ export class GrokAdapter extends LlmAdapter {
743
965
  // A refused credential is reported by the card's account list; only a
744
966
  // genuine discovery failure is worth a warning.
745
967
  if (response.status !== 401 && response.status !== 403) {
746
- this.options.onWarn?.(`xAI model discovery failed (HTTP ${response.status}); using the built-in catalog`);
968
+ this.options.onWarn?.(`xAI model discovery failed (HTTP ${response.status}); keeping the last catalog`);
747
969
  }
748
970
  return undefined;
749
971
  }
750
972
  const payload = await response.json();
751
- const allIds = (payload.data ?? [])
752
- .map((entry) => entry.id)
753
- .filter((id) => typeof id === 'string' && id.length > 0);
754
- // Image models are not chat models; they are picked by an agent, not the picker.
755
- this.imageModel = allIds.find((id) => isImageModel(id));
756
- const rawIds = allIds.filter((id) => !NON_CHAT_ID.test(id));
757
- // Fast/lightspeed variants ride with their base model: they share the base's
758
- // visibility switch and are reached by a picker button, not a separate row.
759
- // Context variants (@500k) also fold into the base; the largest context wins.
760
- this.fastVariants.clear();
761
- this.defaultContext.clear();
762
- for (const id of rawIds) {
763
- this.fastVariants.noteListed(id);
764
- }
765
- this.noteWireVariants(rawIds);
766
- const ids = rawIds
767
- .map((id) => {
768
- if (isFastVariant(id))
769
- return this.fastVariants.pair(id) ?? baseModelOf(id);
770
- const ctx = contextOf(id);
771
- if (ctx !== undefined)
772
- return stripContext(id);
773
- return id;
774
- })
775
- .filter((id, index, list) => list.indexOf(id) === index);
776
- if (ids.length === 0) {
777
- this.options.onWarn?.('xAI model discovery returned an empty catalog; using the built-in catalog');
973
+ const publicRows = Array.isArray(payload.data) ? payload.data : [];
974
+ const cliRows = await this.readCliCatalog(fetchFn, signal, accessToken);
975
+ const listing = { public: publicRows, cli: cliRows };
976
+ const entries = this.adoptListing(listing);
977
+ if (entries === undefined) {
978
+ this.options.onWarn?.('xAI model discovery returned no usable catalog; keeping the last catalog');
778
979
  return undefined;
779
980
  }
780
- const cli = await this.readCliCatalog(fetchFn, signal, accessToken);
781
- for (const [id, meta] of cli.metadata)
782
- this.metadata.set(id, meta);
783
- // The account's own CLI catalog is the only surface that names a fast variant:
784
- // the public `/models` list omits `grok-4.7-build-fast` entirely. Its ids fold
785
- // into the same base rows, and are never added as picker rows of their own.
786
- for (const id of cli.ids) {
787
- this.fastVariants.noteListed(id);
981
+ this.live = entries;
982
+ // The cached table is the endpoint's answer, kept so the next session does not ask
983
+ // again. A cache failure never fails the read that produced it.
984
+ if (this.options.saveListing !== undefined) {
985
+ try {
986
+ await this.options.saveListing(listing);
987
+ }
988
+ catch (error) {
989
+ this.options.onWarn?.(`model catalog cache write failed (${errorChain(error)})`);
990
+ }
788
991
  }
789
- this.noteWireVariants(cli.ids);
790
- return ids;
992
+ return entries.map((entry) => entry.id);
791
993
  }
792
994
  catch (error) {
793
995
  if (signal?.aborted === true)
794
996
  return undefined;
795
- this.options.onWarn?.(`xAI model discovery failed; using the built-in catalog (${errorChain(error)})`);
997
+ this.options.onWarn?.(`xAI model discovery failed; keeping the last catalog (${errorChain(error)})`);
796
998
  return undefined;
797
999
  }
798
1000
  }
@@ -818,57 +1020,34 @@ export class GrokAdapter extends LlmAdapter {
818
1020
  }
819
1021
  }
820
1022
  /**
821
- * Read per-model capabilities from the CLI catalog; failures are silent.
1023
+ * Read the account's CLI catalog; a failure is an empty listing, not an error.
822
1024
  *
823
1025
  * The catalog answers only for the signed-in account's own bearer — an anonymous
824
- * request is refused — and it, unlike the public `/models` list, names the account's
825
- * fast variant.
1026
+ * request is refused — and it, unlike the public `/models` list, states per-model
1027
+ * context windows and reasoning ladders and names the account's fast variant. The rows
1028
+ * are returned verbatim so both the fresh catalog and the cached payload are derived
1029
+ * from the same parse.
826
1030
  *
827
1031
  * @param fetchFn - fetch implementation.
828
1032
  * @param signal - caller cancellation.
829
1033
  * @param accessToken - the account's bearer.
830
- * @returns the capability metadata and every id the catalog named.
1034
+ * @returns the decoded rows, or an empty list when the catalog is unavailable.
831
1035
  */
832
1036
  async readCliCatalog(fetchFn, signal, accessToken) {
833
- const result = new Map();
834
- const ids = [];
835
1037
  try {
836
1038
  const response = await fetchFn(GROK_CLI_MODELS_URL, {
837
1039
  headers: attributedHeaders({ 'authorization': `Bearer ${accessToken}` }),
838
1040
  ...signal === undefined ? {} : { signal },
839
1041
  });
840
1042
  if (!response.ok)
841
- return { metadata: result, ids };
1043
+ return [];
842
1044
  const payload = await response.json();
843
- for (const entry of payload.data ?? []) {
844
- const id = typeof entry.id === 'string' && entry.id.length > 0 ? entry.id : undefined;
845
- if (id === undefined)
846
- continue;
847
- ids.push(id);
848
- const contextWindow = typeof entry.context_window === 'number' && Number.isFinite(entry.context_window)
849
- ? entry.context_window
850
- : undefined;
851
- const supportsEffort = entry.supports_reasoning_effort === true;
852
- const efforts = supportsEffort
853
- ? (entry.reasoning_efforts ?? [])
854
- .map((effort) => effort.value)
855
- .filter((value) => typeof value === 'string' && value.length > 0)
856
- : [];
857
- const defaultEffort = supportsEffort && typeof entry.reasoning_effort === 'string'
858
- && efforts.includes(entry.reasoning_effort)
859
- ? entry.reasoning_effort
860
- : undefined;
861
- result.set(id, {
862
- ...contextWindow === undefined ? {} : { contextWindow },
863
- ...efforts.length === 0 ? {} : { reasoningEfforts: efforts },
864
- ...defaultEffort === undefined ? {} : { defaultEffort },
865
- });
866
- }
1045
+ return Array.isArray(payload.data) ? payload.data : [];
867
1046
  }
868
1047
  catch {
869
1048
  // Capability metadata is a bonus; the id list is what matters.
1049
+ return [];
870
1050
  }
871
- return { metadata: result, ids };
872
1051
  }
873
1052
  }
874
1053
  /**
@@ -960,8 +1139,13 @@ export function createGrokProvider(options) {
960
1139
  const read = await readGrokUsage({ accessToken: token.accessToken, fetchFn: options.discoveryFetchFn ?? proxiedFetch });
961
1140
  return read === undefined ? [] : read.windows;
962
1141
  },
1142
+ ...options.cachedListing === undefined ? {} : { cachedListing: options.cachedListing },
1143
+ ...options.saveListing === undefined ? {} : { saveListing: options.saveListing },
963
1144
  ...options.discoveryFetchFn === undefined ? {} : { fetchFn: options.discoveryFetchFn },
964
1145
  });
1146
+ // Read the cached table now rather than at the first picker query: the Settings page
1147
+ // reads the catalog synchronously, so a request-free start must already have it.
1148
+ void adapter.hydrate().catch(() => undefined);
965
1149
  const spec = {
966
1150
  id: GROK_PROVIDER_ID,
967
1151
  displayName: GROK_PROVIDER_NAME,
@@ -26,6 +26,16 @@ export interface KimiProviderOptions {
26
26
  defaultModel?: string;
27
27
  /** Extra model ids published without shipped metadata. */
28
28
  extraModels?: readonly string[];
29
+ /**
30
+ * The endpoint's last live payload, from this plugin's cache.
31
+ *
32
+ * The cached table is what the picker shows before any request: the endpoint's own
33
+ * answer from the last read, so a release does not need a plugin update and a refresh
34
+ * does not need a request.
35
+ */
36
+ cachedListing?: () => Promise<unknown>;
37
+ /** Record what a live listing just produced, replacing the cached table. */
38
+ saveListing?: (payload: unknown) => Promise<void>;
29
39
  /** Shared model-visibility preferences. */
30
40
  preferences: ModelPreferences;
31
41
  /** Resolve the attachment service per request. */
@@ -99,16 +99,21 @@ export function createKimiProvider(options) {
99
99
  hiddenModels: () => options.preferences.hiddenIds(KIMI_PROVIDER_ID),
100
100
  userModels: () => options.preferences.extraIds(KIMI_PROVIDER_ID),
101
101
  modelOrder: () => options.preferences.orderOf(KIMI_PROVIDER_ID),
102
+ defaultModel: options.defaultModel ?? KIMI_DEFAULT_MODEL,
103
+ ...options.cachedListing === undefined ? {} : { cachedListing: options.cachedListing },
104
+ ...options.saveListing === undefined ? {} : { saveListing: options.saveListing },
102
105
  ...options.extraModels === undefined ? {} : { extraModels: options.extraModels },
103
106
  fetchFn,
104
107
  });
105
- const spec = {
106
- id: KIMI_PROVIDER_ID,
108
+ // Read the cached table now rather than at the first picker query: the Settings page
109
+ // reads the catalog synchronously, so a request-free start must already have it.
110
+ void adapter.hydrate().catch(() => undefined);
111
+ const spec = { id: KIMI_PROVIDER_ID,
107
112
  displayName: KIMI_PROVIDER_NAME,
108
113
  codeUrl: KIMI_CODE_URL,
109
114
  credentialFile: options.credentialFile,
110
115
  discovery: options.discovery,
111
- defaultModel: KIMI_DEFAULT_MODEL,
116
+ defaultModel: options.defaultModel ?? KIMI_DEFAULT_MODEL,
112
117
  catalog: () => adapter.catalogEntries(),
113
118
  catalogOwn: () => adapter.ownCatalogEntries(),
114
119
  fallbackContextWindow: KIMI_CONTEXT_WINDOW,