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
@@ -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) {
@@ -653,6 +868,16 @@ export class GrokAdapter extends LlmAdapter {
653
868
  imageModelId() {
654
869
  return this.imageModel;
655
870
  }
871
+ /**
872
+ * Whether this route has read a model listing yet.
873
+ *
874
+ * A variant is derived from the listing, so until one has been read "this model has no
875
+ * variant" and "the listing has not arrived" look identical from outside; a caller that
876
+ * asks once (the composer's quick-mode button) needs to tell them apart.
877
+ */
878
+ hasListing() {
879
+ return this.live !== undefined;
880
+ }
656
881
  /** The curated catalog, for the Settings page's model list. */
657
882
  catalogEntries() {
658
883
  return this.catalog();
@@ -662,8 +887,6 @@ export class GrokAdapter extends LlmAdapter {
662
887
  return this.shippedCatalog();
663
888
  }
664
889
  /** Build and send one request for one model. */
665
- /** The default context variant per base model, set during discovery. */
666
- defaultContext = new Map();
667
890
  /** Resolve a model id to the wire id the endpoint accepts (largest context). */
668
891
  resolveModelId(model) {
669
892
  return this.defaultContext.get(model) ?? model;
@@ -726,9 +949,18 @@ export class GrokAdapter extends LlmAdapter {
726
949
  };
727
950
  }
728
951
  /**
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.
952
+ * Read both of the endpoint's listings and adopt them as this route's catalog.
953
+ *
954
+ * This is the one place a model table is produced, and it is the endpoint's own answer:
955
+ * what the public list names is what the picker offers, and the account's CLI catalog
956
+ * states the capabilities and names the fast variants that list omits. The rows are also
957
+ * handed to {@link GrokAdapterOptions.saveListing}, so the next session starts from this
958
+ * table instead of asking again — the Settings "refresh models" action is what re-reads
959
+ * it, and a picker query never does.
960
+ *
961
+ * @param accessToken - bearer for the account.
962
+ * @param signal - optional cancellation.
963
+ * @returns the model ids now served, or undefined when the listing is unavailable.
732
964
  */
733
965
  async fetchCatalog(accessToken, signal) {
734
966
  if (!this.options.discovery)
@@ -743,56 +975,36 @@ export class GrokAdapter extends LlmAdapter {
743
975
  // A refused credential is reported by the card's account list; only a
744
976
  // genuine discovery failure is worth a warning.
745
977
  if (response.status !== 401 && response.status !== 403) {
746
- this.options.onWarn?.(`xAI model discovery failed (HTTP ${response.status}); using the built-in catalog`);
978
+ this.options.onWarn?.(`xAI model discovery failed (HTTP ${response.status}); keeping the last catalog`);
747
979
  }
748
980
  return undefined;
749
981
  }
750
982
  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');
983
+ const publicRows = Array.isArray(payload.data) ? payload.data : [];
984
+ const cliRows = await this.readCliCatalog(fetchFn, signal, accessToken);
985
+ const listing = { public: publicRows, cli: cliRows };
986
+ const entries = this.adoptListing(listing);
987
+ if (entries === undefined) {
988
+ this.options.onWarn?.('xAI model discovery returned no usable catalog; keeping the last catalog');
778
989
  return undefined;
779
990
  }
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);
991
+ this.live = entries;
992
+ // The cached table is the endpoint's answer, kept so the next session does not ask
993
+ // again. A cache failure never fails the read that produced it.
994
+ if (this.options.saveListing !== undefined) {
995
+ try {
996
+ await this.options.saveListing(listing);
997
+ }
998
+ catch (error) {
999
+ this.options.onWarn?.(`model catalog cache write failed (${errorChain(error)})`);
1000
+ }
788
1001
  }
789
- this.noteWireVariants(cli.ids);
790
- return ids;
1002
+ return entries.map((entry) => entry.id);
791
1003
  }
792
1004
  catch (error) {
793
1005
  if (signal?.aborted === true)
794
1006
  return undefined;
795
- this.options.onWarn?.(`xAI model discovery failed; using the built-in catalog (${errorChain(error)})`);
1007
+ this.options.onWarn?.(`xAI model discovery failed; keeping the last catalog (${errorChain(error)})`);
796
1008
  return undefined;
797
1009
  }
798
1010
  }
@@ -818,57 +1030,34 @@ export class GrokAdapter extends LlmAdapter {
818
1030
  }
819
1031
  }
820
1032
  /**
821
- * Read per-model capabilities from the CLI catalog; failures are silent.
1033
+ * Read the account's CLI catalog; a failure is an empty listing, not an error.
822
1034
  *
823
1035
  * 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.
1036
+ * request is refused — and it, unlike the public `/models` list, states per-model
1037
+ * context windows and reasoning ladders and names the account's fast variant. The rows
1038
+ * are returned verbatim so both the fresh catalog and the cached payload are derived
1039
+ * from the same parse.
826
1040
  *
827
1041
  * @param fetchFn - fetch implementation.
828
1042
  * @param signal - caller cancellation.
829
1043
  * @param accessToken - the account's bearer.
830
- * @returns the capability metadata and every id the catalog named.
1044
+ * @returns the decoded rows, or an empty list when the catalog is unavailable.
831
1045
  */
832
1046
  async readCliCatalog(fetchFn, signal, accessToken) {
833
- const result = new Map();
834
- const ids = [];
835
1047
  try {
836
1048
  const response = await fetchFn(GROK_CLI_MODELS_URL, {
837
1049
  headers: attributedHeaders({ 'authorization': `Bearer ${accessToken}` }),
838
1050
  ...signal === undefined ? {} : { signal },
839
1051
  });
840
1052
  if (!response.ok)
841
- return { metadata: result, ids };
1053
+ return [];
842
1054
  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
- }
1055
+ return Array.isArray(payload.data) ? payload.data : [];
867
1056
  }
868
1057
  catch {
869
1058
  // Capability metadata is a bonus; the id list is what matters.
1059
+ return [];
870
1060
  }
871
- return { metadata: result, ids };
872
1061
  }
873
1062
  }
874
1063
  /**
@@ -960,8 +1149,13 @@ export function createGrokProvider(options) {
960
1149
  const read = await readGrokUsage({ accessToken: token.accessToken, fetchFn: options.discoveryFetchFn ?? proxiedFetch });
961
1150
  return read === undefined ? [] : read.windows;
962
1151
  },
1152
+ ...options.cachedListing === undefined ? {} : { cachedListing: options.cachedListing },
1153
+ ...options.saveListing === undefined ? {} : { saveListing: options.saveListing },
963
1154
  ...options.discoveryFetchFn === undefined ? {} : { fetchFn: options.discoveryFetchFn },
964
1155
  });
1156
+ // Read the cached table now rather than at the first picker query: the Settings page
1157
+ // reads the catalog synchronously, so a request-free start must already have it.
1158
+ void adapter.hydrate().catch(() => undefined);
965
1159
  const spec = {
966
1160
  id: GROK_PROVIDER_ID,
967
1161
  displayName: GROK_PROVIDER_NAME,
@@ -1009,12 +1203,19 @@ export function createGrokProvider(options) {
1009
1203
  removeModel: (id) => controller.removeModel(id),
1010
1204
  usage: (force) => controller.usage(force),
1011
1205
  reload: () => controller.reload(),
1012
- modelVariants: (id) => {
1206
+ modelVariants: async (id) => {
1207
+ // A variant is known only once the route has read its listing, whose rows name it.
1208
+ // The composer asks exactly once, so the read is awaited here: "no fast variant"
1209
+ // must mean the route really has none, not that its table was still loading.
1210
+ await adapter.hydrate();
1013
1211
  const pair = adapter.fastPairOf(id);
1014
1212
  const image = adapter.imageModelId();
1015
1213
  return {
1016
1214
  ...pair === undefined ? {} : { fast: pair.wireId, fastBase: pair.baseId, fastRung: fastWordOf(pair.wireId) },
1017
1215
  ...image === undefined ? {} : { image },
1216
+ // Nothing read yet: the answer above is provisional, and the caller is told so
1217
+ // rather than being left to read silence as "this model has no variant".
1218
+ ...adapter.hasListing() ? {} : { pending: true },
1018
1219
  };
1019
1220
  },
1020
1221
  };
@@ -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. */