dsh-plugin-subscriptions 0.5.2 → 0.6.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 (60) hide show
  1. package/README.md +79 -5
  2. package/README.zh.md +78 -4
  3. package/lib/auth/rpc.d.ts +64 -13
  4. package/lib/auth/rpc.js +75 -10
  5. package/lib/auth/store.d.ts +75 -17
  6. package/lib/auth/store.js +148 -27
  7. package/lib/client/ImageGenerateToolview.d.ts +1 -1
  8. package/lib/client/SpeedSelect.d.ts +25 -2
  9. package/lib/client/SpeedSelect.js +10 -6
  10. package/lib/client/SubscriptionsSection.d.ts +83 -3
  11. package/lib/client/SubscriptionsSection.js +411 -62
  12. package/lib/client/VideoGenerateToolview.d.ts +1 -1
  13. package/lib/client/index.d.ts +1 -9
  14. package/lib/client/index.js +7 -4
  15. package/lib/client/locales.d.ts +46 -10
  16. package/lib/client/locales.js +46 -10
  17. package/lib/client.js +703 -132
  18. package/lib/client.js.map +1 -1
  19. package/lib/compat.d.ts +36 -0
  20. package/lib/compat.js +20 -0
  21. package/lib/index.d.ts +26 -1
  22. package/lib/index.js +2377 -309
  23. package/lib/model-defaults.d.ts +23 -0
  24. package/lib/model-defaults.js +237 -0
  25. package/lib/providers/accounts.d.ts +102 -0
  26. package/lib/providers/accounts.js +123 -0
  27. package/lib/providers/claude.d.ts +46 -7
  28. package/lib/providers/claude.js +125 -34
  29. package/lib/providers/codex.d.ts +45 -3
  30. package/lib/providers/codex.js +152 -26
  31. package/lib/providers/common.d.ts +87 -6
  32. package/lib/providers/common.js +185 -22
  33. package/lib/providers/copilot.d.ts +32 -3
  34. package/lib/providers/copilot.js +111 -19
  35. package/lib/providers/grok.d.ts +45 -4
  36. package/lib/providers/grok.js +136 -20
  37. package/lib/providers/pool-family.d.ts +56 -0
  38. package/lib/providers/pool-family.js +45 -0
  39. package/lib/providers/pool-health.d.ts +74 -0
  40. package/lib/providers/pool-health.js +148 -0
  41. package/lib/providers/pool-usage.d.ts +78 -0
  42. package/lib/providers/pool-usage.js +185 -0
  43. package/lib/providers/pool.d.ts +107 -0
  44. package/lib/providers/pool.js +371 -0
  45. package/lib/providers/rate-limit.d.ts +192 -0
  46. package/lib/providers/rate-limit.js +338 -0
  47. package/lib/tools/image-generate.d.ts +3 -3
  48. package/lib/tools/image-generate.js +2 -1
  49. package/lib/tools/video-generate.d.ts +2 -2
  50. package/lib/tools/video-generate.js +2 -1
  51. package/lib/tools/x-search.d.ts +2 -2
  52. package/lib/tools/x-search.js +2 -1
  53. package/lib/translate/anthropic.js +5 -4
  54. package/lib/translate/chat-completions.js +5 -4
  55. package/lib/translate/responses.js +5 -4
  56. package/package.json +21 -21
  57. package/lib/providers/antigravity.d.ts +0 -90
  58. package/lib/providers/antigravity.js +0 -392
  59. package/lib/translate/antigravity.d.ts +0 -110
  60. package/lib/translate/antigravity.js +0 -303
@@ -17,8 +17,10 @@ import { EMPTY_RESPONSE_CODE, errorChain, LlmAdapter, LlmError, ReasoningEffortI
17
17
  import { resolveImages } from '../translate/resolved.js';
18
18
  import { streamChatCompletions, toChatMessages, toChatTools, } from '../translate/chat-completions.js';
19
19
  import { streamResponses, toResponsesInput, toResponsesTools } from '../translate/responses.js';
20
- import { httpLlmError, idleWatchdog, mapFetchFailure, ModelCatalogCache, discoverOrRetryAuth, isMissingOrInvalidCredential, oauthEndpointError, OAuthEndpointError, TokenManager, } from './common.js';
20
+ import { httpLlmError, idleWatchdog, mapFetchFailure, mergeReasoning, ModelCatalogCache, discoverAcrossAccounts, discoverOrRetryAuth, isDiscoveryAborted, isMissingOrInvalidCredential, oauthEndpointError, OAuthEndpointError, } from './common.js';
21
+ import { AccountTokenManager, DISCOVERY_TIMEOUT_MS, unionAccountCatalogs } from './accounts.js';
21
22
  import { proxiedFetch } from '../http.js';
23
+ import { DEFAULT_RATE_LIMIT_WAIT, DEFAULT_RETRY, subscriptionRetryPolicy, } from './rate-limit.js';
22
24
  /**
23
25
  * Client id of the VS Code Copilot Chat GitHub App (pi-mono and
24
26
  * copilot2api-go use the same value): the app is pre-authorized for the
@@ -243,15 +245,17 @@ function copilotReasoning(entry) {
243
245
  * reasoning efforts (the endpoint discloses no default, so none is claimed).
244
246
  * @param session - the stored session (used as-is; never refreshed here).
245
247
  * @param fetchFn - fetch implementation (injectable for tests).
248
+ * @param signal - caller cancellation (pool-assembly timeout).
246
249
  * @returns discovered chat models in endpoint order.
247
250
  */
248
- export async function fetchCopilotModels(session, fetchFn = proxiedFetch) {
251
+ export async function fetchCopilotModels(session, fetchFn = proxiedFetch, signal) {
249
252
  const response = await fetchFn(COPILOT_MODELS_URL, {
250
253
  headers: {
251
254
  'authorization': `Bearer ${session.accessToken}`,
252
255
  'accept': 'application/json',
253
256
  ...copilotHeaders(false, await latestVsCodeVersion(fetchFn)),
254
257
  },
258
+ ...signal === undefined ? {} : { signal },
255
259
  });
256
260
  if (!response.ok)
257
261
  throw await oauthEndpointError(response, 'copilot models');
@@ -510,6 +514,10 @@ export class CopilotResponsesItemNormalizer {
510
514
  export class CopilotAdapter extends LlmAdapter {
511
515
  options;
512
516
  catalog;
517
+ /** In-memory catalogs for non-default accounts (the persisted cache is the default's). */
518
+ accountCatalogs = new Map();
519
+ /** Account whose snapshot currently lives in {@link catalog}; cleared on default change. */
520
+ catalogOwner;
513
521
  /**
514
522
  * [2026-08-23]-[a reasoning model continuing a tool chain must get its
515
523
  * reasoning back or it restarts from scratch every tool round trip; the
@@ -532,12 +540,44 @@ export class CopilotAdapter extends LlmAdapter {
532
540
  this.catalog = new ModelCatalogCache(options.catalogStore);
533
541
  }
534
542
  /** Discovery fetcher: resolves the session through the refresh-aware path. */
535
- async fetchCatalog() {
536
- return fetchCopilotModels(await this.options.tokens.session(), this.options.fetchFn);
543
+ async fetchCatalog(account, signal) {
544
+ return fetchCopilotModels(await this.options.tokens.session(account), this.options.fetchFn, signal);
545
+ }
546
+ /** Drop cached catalogs after login/logout so the next list does not reuse a stale plan. */
547
+ clearAccountCatalog(account) {
548
+ if (account === undefined)
549
+ this.accountCatalogs.clear();
550
+ else
551
+ this.accountCatalogs.delete(account);
552
+ if (account === undefined || this.catalogOwner === account || this.catalogOwner === undefined) {
553
+ this.catalogOwner = undefined;
554
+ this.catalog.invalidate();
555
+ }
556
+ }
557
+ /** Persisted cache for the default account; a throwaway cache for any other. */
558
+ async catalogFor(account) {
559
+ const defaultKey = await this.options.tokens.defaultAccount();
560
+ const key = account ?? defaultKey;
561
+ if (key === undefined || key === defaultKey) {
562
+ if (this.catalogOwner !== undefined && this.catalogOwner !== defaultKey) {
563
+ this.catalog.invalidate();
564
+ }
565
+ this.catalogOwner = defaultKey;
566
+ return this.catalog;
567
+ }
568
+ let cache = this.accountCatalogs.get(key);
569
+ if (cache === undefined) {
570
+ cache = new ModelCatalogCache();
571
+ this.accountCatalogs.set(key, cache);
572
+ }
573
+ return cache;
537
574
  }
538
575
  providerInfo(provider) {
539
576
  return { id: provider, name: 'GitHub Copilot' };
540
577
  }
578
+ providerRetryPolicy(provider) {
579
+ return subscriptionRetryPolicy(DEFAULT_RETRY, this.options.rateLimit ?? DEFAULT_RATE_LIMIT_WAIT, `copilot: provider "${provider}" retryPolicy`);
580
+ }
541
581
  staticModels(provider) {
542
582
  return this.options.models.map(model => ({
543
583
  provider,
@@ -547,17 +587,34 @@ export class CopilotAdapter extends LlmAdapter {
547
587
  }));
548
588
  }
549
589
  async listModels(provider) {
550
- // Not logged in → empty catalog, so the web picker drops the provider.
551
- const session = await this.options.tokens.peek();
552
- if (session === undefined)
590
+ const own = await this.listOwnModels(provider);
591
+ const pool = this.options.pool?.();
592
+ if (pool === undefined)
593
+ return own;
594
+ const extra = await pool.modelsForProvider(provider);
595
+ const seen = new Set(own.map(model => model.id));
596
+ // Account pools reuse the catalog row; only configured tiers are extra.
597
+ return [...own, ...extra.filter(model => !seen.has(model.id))];
598
+ }
599
+ /** The provider's own catalog: union of every account, or one account when named. */
600
+ async listOwnModels(provider, account, signal) {
601
+ if (account === undefined) {
602
+ const accounts = (await this.options.tokens.list()).map(entry => entry.key);
603
+ if (accounts.length === 0)
604
+ return [];
605
+ return unionAccountCatalogs(accounts, (key, accountSignal) => this.listOwnModels(provider, key, accountSignal), { timeoutMs: DISCOVERY_TIMEOUT_MS, ...signal === undefined ? {} : { signal } });
606
+ }
607
+ if (!await this.options.tokens.hasSession(account)) {
553
608
  return [];
609
+ }
554
610
  if (!this.options.discovery)
555
611
  return this.staticModels(provider);
612
+ const catalog = await this.catalogFor(account);
556
613
  try {
557
614
  // The fetcher runs only on a cache miss, and resolves the session
558
615
  // through the refresh-aware path so an expired access token renews here
559
616
  // instead of failing discovery into the static fallback.
560
- const discovered = await discoverOrRetryAuth(force => this.options.tokens.session(force), this.catalog, () => this.catalog.get(() => this.fetchCatalog()));
617
+ const discovered = await discoverOrRetryAuth(force => this.options.tokens.session(account, force), catalog, () => catalog.get(() => this.fetchCatalog(account, signal)));
561
618
  return discovered.map(model => ({
562
619
  provider,
563
620
  id: model.id,
@@ -567,6 +624,8 @@ export class CopilotAdapter extends LlmAdapter {
567
624
  }));
568
625
  }
569
626
  catch (error) {
627
+ if (isDiscoveryAborted(error, signal))
628
+ throw error;
570
629
  // A permanent refresh failure deletes the stored session: the provider
571
630
  // is logged out, so hide it instead of showing a stale static catalog.
572
631
  if (isMissingOrInvalidCredential(error))
@@ -584,8 +643,12 @@ export class CopilotAdapter extends LlmAdapter {
584
643
  async discovered(model) {
585
644
  if (!this.options.discovery)
586
645
  return undefined;
587
- const models = await this.catalog.resolve(() => this.fetchCatalog());
588
- return models?.find(entry => entry.id === model);
646
+ const accounts = (await this.options.tokens.list()).map(entry => entry.key);
647
+ return discoverAcrossAccounts(accounts, async (account) => {
648
+ const catalog = await this.catalogFor(account);
649
+ const models = await catalog.resolve(() => this.fetchCatalog(account));
650
+ return models?.find(entry => entry.id === model);
651
+ });
589
652
  }
590
653
  /**
591
654
  * [2026-08-23]-[a manually configured responses-only model combined with
@@ -699,8 +762,23 @@ export class CopilotAdapter extends LlmAdapter {
699
762
  this.replayByScope.clear();
700
763
  }
701
764
  async resolveModel(provider, model) {
765
+ const pool = this.options.pool?.();
766
+ if (pool !== undefined && await pool.owns(provider, model)) {
767
+ return pool.resolveModel(provider, model);
768
+ }
769
+ return this.resolveOwnModel(provider, model);
770
+ }
771
+ /** Capability resolution of the provider's own models (the pool resolves members here). */
772
+ async resolveOwnModel(provider, model) {
702
773
  const discovered = await this.discovered(model);
703
774
  const configured = this.options.models.find(entry => entry.id === model);
775
+ // Efforts come from the discovered catalog's reasoning_effort array; a
776
+ // model that did not advertise one exposes none, so the harness rejects
777
+ // an explicit effort before provider I/O instead of the API 400ing
778
+ // (Copilot returns invalid_request_body for models that cannot reason).
779
+ // A configured default effort still merges in: the picker then
780
+ // preselects it even for models the catalog does not cover.
781
+ const reasoning = mergeReasoning(this.options.defaultEffortOf?.(model), discovered?.reasoning);
704
782
  return {
705
783
  provider,
706
784
  id: model,
@@ -709,14 +787,22 @@ export class CopilotAdapter extends LlmAdapter {
709
787
  inputModalities: discovered?.inputModalities ?? configured?.inputModalities ?? ['text'],
710
788
  context: { contextWindow: discovered?.contextWindow ?? configured?.contextWindow ?? COPILOT_CONTEXT_WINDOW },
711
789
  defaultMaxTokens: configured?.maxTokens ?? COPILOT_DEFAULT_MAX_TOKENS,
712
- // Efforts come from the discovered catalog's reasoning_effort array; a
713
- // model that did not advertise one exposes none, so the harness rejects
714
- // an explicit effort before provider I/O instead of the API 400ing
715
- // (Copilot returns invalid_request_body for models that cannot reason).
716
- ...discovered?.reasoning === undefined ? {} : { reasoning: discovered.reasoning },
790
+ ...reasoning === undefined ? {} : { reasoning },
717
791
  };
718
792
  }
719
793
  async *stream(options) {
794
+ const pool = this.options.pool?.();
795
+ if (pool !== undefined && await pool.owns(options.provider, options.model)) {
796
+ yield* pool.stream(options);
797
+ return;
798
+ }
799
+ yield* this.streamCore(options);
800
+ }
801
+ /** Pool seam: stream through one specific account instead of the default. */
802
+ streamAccount(options, account) {
803
+ return this.streamCore(options, account);
804
+ }
805
+ async *streamCore(options, account) {
720
806
  const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
721
807
  try {
722
808
  // The discovered catalog decides the protocol: `/responses`-only model
@@ -725,7 +811,7 @@ export class CopilotAdapter extends LlmAdapter {
725
811
  // tools with a reasoning effort (gpt-5.4 400s on the chat wire then).
726
812
  // A configured `wire` outranks the catalog (see configuredWireEntry).
727
813
  const wire = copilotRequestWire(this.configuredWireEntry(options.model) ?? await this.discovered(options.model), options);
728
- let session = await this.options.tokens.session();
814
+ let session = await this.options.tokens.session(account);
729
815
  // Replay scope: account identity × conversation × model (see
730
816
  // replayScope); a Copilot-token refresh preserves the GitHub token, so
731
817
  // the 401 retry below reuses it too.
@@ -737,11 +823,17 @@ export class CopilotAdapter extends LlmAdapter {
737
823
  // means GitHub raised its minimum VS Code version, and only a fresh
738
824
  // Editor-Version header fixes that (a new token does not).
739
825
  await latestVsCodeVersion(this.options.fetchFn ?? proxiedFetch, true);
740
- session = await this.options.tokens.session(true);
826
+ session = await this.options.tokens.session(account, true);
741
827
  response = await this.request(options, session, watchdog.signal, wire, scope);
742
828
  }
743
- if (!response.ok)
744
- throw await httpLlmError(response, 'copilot API');
829
+ if (!response.ok) {
830
+ throw await httpLlmError(response, 'copilot API', {
831
+ // Copilot has no provider-specific reset reader yet. The shared
832
+ // mapper still honors its generic retry-after header and warns with
833
+ // rate-limit-shaped headers/body when GitHub sends another signal.
834
+ ...this.options.onWarn === undefined ? {} : { onWarn: this.options.onWarn },
835
+ });
836
+ }
745
837
  if (response.body === null) {
746
838
  throw new LlmError('copilot API returned no response body', EMPTY_RESPONSE_CODE);
747
839
  }
@@ -7,14 +7,27 @@ import { LlmAdapter } from '@deepseek-ai/dsh-llm';
7
7
  import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm';
8
8
  import type { FlowSpec } from '../auth/oauth-flow.js';
9
9
  import type { GrokSession } from '../auth/store.js';
10
+ import type { PoolAdapter } from './pool.js';
10
11
  import type { AttachmentStore } from '@deepseek-ai/dsh-attachment';
11
- import { TokenManager } from './common.js';
12
+ import { AccountTokenManager } from './accounts.js';
12
13
  import type { CatalogPersistence, DiscoveredModel, FetchFn, ModelEntry, ProviderUsage } from './common.js';
14
+ import type { RateLimitResetReader, RateLimitWait } from './rate-limit.js';
13
15
  export declare const GROK_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
14
16
  export declare const GROK_DISCOVERY_URL = "https://auth.x.ai/.well-known/openid-configuration";
15
17
  export declare const GROK_API_URL = "https://api.x.ai/v1/responses";
16
18
  /** Refresh when the access token has less than this much life left. */
17
19
  export declare const GROK_PREEMPT_MS: number;
20
+ /**
21
+ * Reads the reset instant of the xAI window that rejected a request.
22
+ *
23
+ * Body only. xAI serves the OpenAI-compatible `x-ratelimit-reset-*` family,
24
+ * whose values are rollover durations (`6m0s`) present on every response, one
25
+ * per bucket — on a 429 the earliest of them is usually a bucket with room
26
+ * (`0s` for the request bucket while the token bucket is the one exhausted),
27
+ * which would burn the whole retry budget in seconds. They reach the operator
28
+ * through `rateLimitDiagnostics` instead.
29
+ */
30
+ export declare const grokRateLimitReset: RateLimitResetReader;
18
31
  /** Discovered OIDC endpoints for the xAI authorization server. */
19
32
  export interface GrokDiscovery {
20
33
  authorizationEndpoint: string;
@@ -93,9 +106,10 @@ type GrokCliModelMeta = Partial<Pick<DiscoveredModel, 'name' | 'description' | '
93
106
  * Fetch the CLI catalog and index its per-model metadata by model id.
94
107
  * @param session - the stored session (used as-is; never refreshed here).
95
108
  * @param fetchFn - fetch implementation (injectable for tests).
109
+ * @param signal - caller cancellation (pool-assembly timeout).
96
110
  * @returns model id → contributed metadata.
97
111
  */
98
- export declare function fetchGrokCliCatalog(session: GrokSession, fetchFn?: FetchFn): Promise<Map<string, GrokCliModelMeta>>;
112
+ export declare function fetchGrokCliCatalog(session: GrokSession, fetchFn?: FetchFn, signal?: AbortSignal): Promise<Map<string, GrokCliModelMeta>>;
99
113
  /**
100
114
  * Fetch the live grok model list, enriched with the CLI catalog's per-model
101
115
  * metadata (display name, context window, reasoning efforts). The api.x.ai
@@ -109,14 +123,17 @@ export declare function fetchGrokCliCatalog(session: GrokSession, fetchFn?: Fetc
109
123
  * @param onWarn - warning sink for a failed CLI catalog fetch.
110
124
  * @param previous - last-known catalog used to keep enrichment when the CLI
111
125
  * catalog is down or omits a model.
126
+ * @param signal - caller cancellation (pool-assembly timeout).
112
127
  * @returns discovered chat models in endpoint order.
113
128
  */
114
- export declare function fetchGrokModels(session: GrokSession, fetchFn?: FetchFn, onWarn?: (message: string) => void, previous?: readonly DiscoveredModel[]): Promise<DiscoveredModel[]>;
129
+ export declare function fetchGrokModels(session: GrokSession, fetchFn?: FetchFn, onWarn?: (message: string) => void, previous?: readonly DiscoveredModel[], signal?: AbortSignal): Promise<DiscoveredModel[]>;
115
130
  /** Constructor dependencies for {@link GrokAdapter}. */
116
131
  export interface GrokAdapterOptions {
117
132
  models: readonly ModelEntry[];
118
133
  streamIdleTimeoutMs: number;
119
- tokens: TokenManager<GrokSession>;
134
+ tokens: AccountTokenManager<GrokSession>;
135
+ /** Late-bound pool facade (wired after adapter construction); pools list under their first member's provider. */
136
+ pool?: () => PoolAdapter | undefined;
120
137
  /** Whether to fetch the live catalog when logged in (false when config `models` overrides). */
121
138
  discovery: boolean;
122
139
  /** Warning sink for discovery failures that fall back to the static catalog. */
@@ -127,18 +144,37 @@ export interface GrokAdapterOptions {
127
144
  resolveAttachments?: () => AttachmentStore | undefined;
128
145
  /** Durable catalog store seeding capability metadata across restarts. */
129
146
  catalogStore?: CatalogPersistence;
147
+ /**
148
+ * Per-model default reasoning effort override (the Settings page's picker).
149
+ * Returns the user-configured default for one model, or undefined to follow
150
+ * the provider's own default.
151
+ */
152
+ defaultEffortOf?: (model: string) => string | undefined;
153
+ /** How long this route may hold a turn open waiting for a rate-limit window; defaults to waiting on, six-hour ceiling. */
154
+ rateLimit?: RateLimitWait;
130
155
  }
131
156
  /** Grok wire adapter: one instance serves the `grok` provider route. */
132
157
  export declare class GrokAdapter extends LlmAdapter {
133
158
  private readonly options;
134
159
  private readonly catalog;
160
+ /** In-memory catalogs for non-default accounts (the persisted cache is the default's). */
161
+ private readonly accountCatalogs;
162
+ /** Account whose snapshot currently lives in {@link catalog}; cleared on default change. */
163
+ private catalogOwner;
135
164
  constructor(options: GrokAdapterOptions);
136
165
  /** Discovery fetcher: resolves the session through the refresh-aware path. */
137
166
  private fetchCatalog;
167
+ /** Drop cached catalogs after login/logout so the next list does not reuse a stale plan. */
168
+ clearAccountCatalog(account?: string): void;
169
+ /** Persisted cache for the default account; a throwaway cache for any other. */
170
+ private catalogFor;
138
171
  private listed;
139
172
  providerInfo(provider: string): LlmProviderInfo;
173
+ providerRetryPolicy(provider: string): import("@deepseek-ai/dsh-llm").ResolvedRetryPolicy;
140
174
  private staticModels;
141
175
  listModels(provider: string): Promise<readonly LlmModelInfo[]>;
176
+ /** The provider's own catalog: union of every account, or one account when named. */
177
+ listOwnModels(provider: string, account?: string, signal?: AbortSignal): Promise<readonly LlmModelInfo[]>;
142
178
  /**
143
179
  * The discovered entry for one model. Resolved through the cache's
144
180
  * stale-while-revalidate path: capability metadata must stay stable across
@@ -149,7 +185,12 @@ export declare class GrokAdapter extends LlmAdapter {
149
185
  */
150
186
  private discovered;
151
187
  resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo>;
188
+ /** Capability resolution of the provider's own models (the pool resolves members here). */
189
+ resolveOwnModel(provider: string, model: string): Promise<LlmResolvedModelInfo>;
152
190
  stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
191
+ /** Pool seam: stream through one specific account instead of the default. */
192
+ streamAccount(options: GenerateOptions, account: string): AsyncIterable<StreamChunk>;
193
+ private streamCore;
153
194
  private request;
154
195
  }
155
196
  export {};
@@ -7,8 +7,10 @@ import { attributionHeaders, EMPTY_RESPONSE_CODE, errorChain, LlmAdapter, LlmErr
7
7
  import { decodeJwtPayload } from '../auth/jwt.js';
8
8
  import { resolveImages } from '../translate/resolved.js';
9
9
  import { streamResponses, toResponsesInput, toResponsesTools } from '../translate/responses.js';
10
- import { httpLlmError, idleWatchdog, mapFetchFailure, ModelCatalogCache, discoverOrRetryAuth, isMissingOrInvalidCredential, oauthEndpointError, OAuthEndpointError, TokenManager, } from './common.js';
10
+ import { httpLlmError, idleWatchdog, mapFetchFailure, mergeReasoning, ModelCatalogCache, discoverAcrossAccounts, discoverOrRetryAuth, isDiscoveryAborted, isMissingOrInvalidCredential, oauthEndpointError, OAuthEndpointError, } from './common.js';
11
+ import { AccountTokenManager, DISCOVERY_TIMEOUT_MS, unionAccountCatalogs } from './accounts.js';
11
12
  import { proxiedFetch } from '../http.js';
13
+ import { DEFAULT_RATE_LIMIT_WAIT, DEFAULT_RETRY, jsonBody, resetFromFields, subscriptionRetryPolicy, } from './rate-limit.js';
12
14
  export const GROK_CLIENT_ID = 'b1a00492-073a-47ea-816f-4c329264a828';
13
15
  export const GROK_DISCOVERY_URL = 'https://auth.x.ai/.well-known/openid-configuration';
14
16
  export const GROK_API_URL = 'https://api.x.ai/v1/responses';
@@ -18,6 +20,19 @@ const GROK_CONTEXT_WINDOW = 256_000;
18
20
  const GROK_DEFAULT_MAX_TOKENS = 32_000;
19
21
  /** Refresh when the access token has less than this much life left. */
20
22
  export const GROK_PREEMPT_MS = 2 * 60_000;
23
+ /** Body fields xAI uses to name a delay or reset. */
24
+ const GROK_RESET_FIELDS = ['retry_after', 'retry_after_seconds', 'resets_at', 'reset_at'];
25
+ /**
26
+ * Reads the reset instant of the xAI window that rejected a request.
27
+ *
28
+ * Body only. xAI serves the OpenAI-compatible `x-ratelimit-reset-*` family,
29
+ * whose values are rollover durations (`6m0s`) present on every response, one
30
+ * per bucket — on a 429 the earliest of them is usually a bucket with room
31
+ * (`0s` for the request bucket while the token bucket is the one exhausted),
32
+ * which would burn the whole retry budget in seconds. They reach the operator
33
+ * through `rateLimitDiagnostics` instead.
34
+ */
35
+ export const grokRateLimitReset = (_response, body, now) => resetFromFields(jsonBody(body), GROK_RESET_FIELDS, now);
21
36
  /** A discovered URL must be https on x.ai or a subdomain; anything else is a hostile document. */
22
37
  function assertXaiEndpoint(url, field) {
23
38
  let parsed;
@@ -312,9 +327,10 @@ function grokCliReasoning(entry) {
312
327
  * Fetch the CLI catalog and index its per-model metadata by model id.
313
328
  * @param session - the stored session (used as-is; never refreshed here).
314
329
  * @param fetchFn - fetch implementation (injectable for tests).
330
+ * @param signal - caller cancellation (pool-assembly timeout).
315
331
  * @returns model id → contributed metadata.
316
332
  */
317
- export async function fetchGrokCliCatalog(session, fetchFn = proxiedFetch) {
333
+ export async function fetchGrokCliCatalog(session, fetchFn = proxiedFetch, signal) {
318
334
  const response = await fetchFn(GROK_CLI_MODELS_URL, {
319
335
  headers: {
320
336
  'authorization': `Bearer ${session.accessToken}`,
@@ -323,6 +339,7 @@ export async function fetchGrokCliCatalog(session, fetchFn = proxiedFetch) {
323
339
  'accept': 'application/json',
324
340
  ...attributionHeaders(),
325
341
  },
342
+ ...signal === undefined ? {} : { signal },
326
343
  });
327
344
  if (!response.ok)
328
345
  throw await oauthEndpointError(response, 'grok CLI catalog');
@@ -384,9 +401,10 @@ function grokPriorMeta(prior) {
384
401
  * @param onWarn - warning sink for a failed CLI catalog fetch.
385
402
  * @param previous - last-known catalog used to keep enrichment when the CLI
386
403
  * catalog is down or omits a model.
404
+ * @param signal - caller cancellation (pool-assembly timeout).
387
405
  * @returns discovered chat models in endpoint order.
388
406
  */
389
- export async function fetchGrokModels(session, fetchFn = proxiedFetch, onWarn, previous) {
407
+ export async function fetchGrokModels(session, fetchFn = proxiedFetch, onWarn, previous, signal) {
390
408
  const previousById = previous === undefined || previous.length === 0
391
409
  ? undefined
392
410
  : new Map(previous.map(model => [model.id, model]));
@@ -397,8 +415,11 @@ export async function fetchGrokModels(session, fetchFn = proxiedFetch, onWarn, p
397
415
  'accept': 'application/json',
398
416
  ...attributionHeaders(),
399
417
  },
418
+ ...signal === undefined ? {} : { signal },
400
419
  }),
401
- fetchGrokCliCatalog(session, fetchFn).catch((error) => {
420
+ fetchGrokCliCatalog(session, fetchFn, signal).catch((error) => {
421
+ if (isDiscoveryAborted(error, signal))
422
+ throw error;
402
423
  onWarn?.(previousById === undefined
403
424
  ? `grok CLI catalog fetch failed; reasoning efforts are unavailable (${errorChain(error)})`
404
425
  : `grok CLI catalog fetch failed; keeping last-known reasoning efforts (${errorChain(error)})`);
@@ -436,14 +457,50 @@ export async function fetchGrokModels(session, fetchFn = proxiedFetch, onWarn, p
436
457
  export class GrokAdapter extends LlmAdapter {
437
458
  options;
438
459
  catalog;
460
+ /** In-memory catalogs for non-default accounts (the persisted cache is the default's). */
461
+ accountCatalogs = new Map();
462
+ /** Account whose snapshot currently lives in {@link catalog}; cleared on default change. */
463
+ catalogOwner;
439
464
  constructor(options) {
440
465
  super();
441
466
  this.options = options;
442
467
  this.catalog = new ModelCatalogCache(options.catalogStore);
443
468
  }
444
469
  /** Discovery fetcher: resolves the session through the refresh-aware path. */
445
- async fetchCatalog() {
446
- return fetchGrokModels(await this.options.tokens.session(), this.options.fetchFn, this.options.onWarn, this.catalog.lastKnown());
470
+ async fetchCatalog(account, signal) {
471
+ const lastKnown = account === undefined || account === await this.options.tokens.defaultAccount()
472
+ ? this.catalog.lastKnown()
473
+ : this.accountCatalogs.get(account)?.lastKnown();
474
+ return fetchGrokModels(await this.options.tokens.session(account), this.options.fetchFn, this.options.onWarn, lastKnown, signal);
475
+ }
476
+ /** Drop cached catalogs after login/logout so the next list does not reuse a stale plan. */
477
+ clearAccountCatalog(account) {
478
+ if (account === undefined)
479
+ this.accountCatalogs.clear();
480
+ else
481
+ this.accountCatalogs.delete(account);
482
+ if (account === undefined || this.catalogOwner === account || this.catalogOwner === undefined) {
483
+ this.catalogOwner = undefined;
484
+ this.catalog.invalidate();
485
+ }
486
+ }
487
+ /** Persisted cache for the default account; a throwaway cache for any other. */
488
+ async catalogFor(account) {
489
+ const defaultKey = await this.options.tokens.defaultAccount();
490
+ const key = account ?? defaultKey;
491
+ if (key === undefined || key === defaultKey) {
492
+ if (this.catalogOwner !== undefined && this.catalogOwner !== defaultKey) {
493
+ this.catalog.invalidate();
494
+ }
495
+ this.catalogOwner = defaultKey;
496
+ return this.catalog;
497
+ }
498
+ let cache = this.accountCatalogs.get(key);
499
+ if (cache === undefined) {
500
+ cache = new ModelCatalogCache();
501
+ this.accountCatalogs.set(key, cache);
502
+ }
503
+ return cache;
447
504
  }
448
505
  listed(provider, discovered) {
449
506
  return discovered.map(model => ({
@@ -457,6 +514,9 @@ export class GrokAdapter extends LlmAdapter {
457
514
  providerInfo(provider) {
458
515
  return { id: provider, name: 'Grok (Subscription)' };
459
516
  }
517
+ providerRetryPolicy(provider) {
518
+ return subscriptionRetryPolicy(DEFAULT_RETRY, this.options.rateLimit ?? DEFAULT_RATE_LIMIT_WAIT, `grok: provider "${provider}" retryPolicy`);
519
+ }
460
520
  staticModels(provider) {
461
521
  return this.options.models.map(model => ({
462
522
  provider,
@@ -466,19 +526,38 @@ export class GrokAdapter extends LlmAdapter {
466
526
  }));
467
527
  }
468
528
  async listModels(provider) {
469
- // Not logged in → empty catalog, so the web picker drops the provider.
470
- const session = await this.options.tokens.peek();
471
- if (session === undefined)
529
+ const own = await this.listOwnModels(provider);
530
+ const pool = this.options.pool?.();
531
+ if (pool === undefined)
532
+ return own;
533
+ const extra = await pool.modelsForProvider(provider);
534
+ const seen = new Set(own.map(model => model.id));
535
+ // Account pools reuse the catalog row; only configured tiers are extra.
536
+ return [...own, ...extra.filter(model => !seen.has(model.id))];
537
+ }
538
+ /** The provider's own catalog: union of every account, or one account when named. */
539
+ async listOwnModels(provider, account, signal) {
540
+ if (account === undefined) {
541
+ const accounts = (await this.options.tokens.list()).map(entry => entry.key);
542
+ if (accounts.length === 0)
543
+ return [];
544
+ return unionAccountCatalogs(accounts, (key, accountSignal) => this.listOwnModels(provider, key, accountSignal), { timeoutMs: DISCOVERY_TIMEOUT_MS, ...signal === undefined ? {} : { signal } });
545
+ }
546
+ if (!await this.options.tokens.hasSession(account)) {
472
547
  return [];
548
+ }
473
549
  if (!this.options.discovery)
474
550
  return this.staticModels(provider);
551
+ const catalog = await this.catalogFor(account);
475
552
  try {
476
553
  // The fetcher runs only on a cache miss, and resolves the session
477
554
  // through the refresh-aware path so an expired access token renews here
478
555
  // instead of failing discovery into the static fallback.
479
- return this.listed(provider, await discoverOrRetryAuth(force => this.options.tokens.session(force), this.catalog, () => this.catalog.get(() => this.fetchCatalog())));
556
+ return this.listed(provider, await discoverOrRetryAuth(force => this.options.tokens.session(account, force), catalog, () => catalog.get(() => this.fetchCatalog(account, signal))));
480
557
  }
481
558
  catch (error) {
559
+ if (isDiscoveryAborted(error, signal))
560
+ throw error;
482
561
  // A permanent refresh failure deletes the stored session: the provider
483
562
  // is logged out, so hide it instead of showing a stale static catalog.
484
563
  if (isMissingOrInvalidCredential(error))
@@ -498,12 +577,30 @@ export class GrokAdapter extends LlmAdapter {
498
577
  async discovered(model) {
499
578
  if (!this.options.discovery)
500
579
  return undefined;
501
- const models = await this.catalog.resolve(() => this.fetchCatalog());
502
- return models?.find(entry => entry.id === model);
580
+ const accounts = (await this.options.tokens.list()).map(entry => entry.key);
581
+ return discoverAcrossAccounts(accounts, async (account) => {
582
+ const catalog = await this.catalogFor(account);
583
+ const models = await catalog.resolve(() => this.fetchCatalog(account));
584
+ return models?.find(entry => entry.id === model);
585
+ });
503
586
  }
504
587
  async resolveModel(provider, model) {
588
+ const pool = this.options.pool?.();
589
+ if (pool !== undefined && await pool.owns(provider, model)) {
590
+ return pool.resolveModel(provider, model);
591
+ }
592
+ return this.resolveOwnModel(provider, model);
593
+ }
594
+ /** Capability resolution of the provider's own models (the pool resolves members here). */
595
+ async resolveOwnModel(provider, model) {
505
596
  const discovered = await this.discovered(model);
506
597
  const configured = this.options.models.find(entry => entry.id === model);
598
+ // Efforts come from the discovered CLI catalog; models it does not
599
+ // cover expose none, so the harness rejects explicit efforts before
600
+ // provider I/O instead of the API 400ing. A configured default effort
601
+ // still merges in: the picker then preselects it even for models the
602
+ // CLI catalog does not cover.
603
+ const reasoning = mergeReasoning(this.options.defaultEffortOf?.(model), discovered?.reasoning);
507
604
  return {
508
605
  provider,
509
606
  id: model,
@@ -512,24 +609,37 @@ export class GrokAdapter extends LlmAdapter {
512
609
  inputModalities: configured?.inputModalities ?? grokModalities(model),
513
610
  context: { contextWindow: discovered?.contextWindow ?? configured?.contextWindow ?? GROK_CONTEXT_WINDOW },
514
611
  defaultMaxTokens: configured?.maxTokens ?? GROK_DEFAULT_MAX_TOKENS,
515
- // Efforts come from the discovered CLI catalog; models it does not
516
- // cover expose none, so the harness rejects explicit efforts before
517
- // provider I/O instead of the API 400ing.
518
- ...discovered?.reasoning === undefined ? {} : { reasoning: discovered.reasoning },
612
+ ...reasoning === undefined ? {} : { reasoning },
519
613
  };
520
614
  }
521
615
  async *stream(options) {
616
+ const pool = this.options.pool?.();
617
+ if (pool !== undefined && await pool.owns(options.provider, options.model)) {
618
+ yield* pool.stream(options);
619
+ return;
620
+ }
621
+ yield* this.streamCore(options);
622
+ }
623
+ /** Pool seam: stream through one specific account instead of the default. */
624
+ streamAccount(options, account) {
625
+ return this.streamCore(options, account);
626
+ }
627
+ async *streamCore(options, account) {
522
628
  const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
523
629
  try {
524
- let session = await this.options.tokens.session();
630
+ let session = await this.options.tokens.session(account);
525
631
  let response = await this.request(options, session, watchdog.signal);
526
632
  if (response.status === 401) {
527
633
  // One forced refresh + retry on an unexpired-but-rejected token.
528
- session = await this.options.tokens.session(true);
634
+ session = await this.options.tokens.session(account, true);
529
635
  response = await this.request(options, session, watchdog.signal);
530
636
  }
531
- if (!response.ok)
532
- throw await httpLlmError(response, 'grok API');
637
+ if (!response.ok) {
638
+ throw await httpLlmError(response, 'grok API', {
639
+ rateLimitReset: grokRateLimitReset,
640
+ ...this.options.onWarn === undefined ? {} : { onWarn: this.options.onWarn },
641
+ });
642
+ }
533
643
  if (response.body === null) {
534
644
  throw new LlmError('grok API returned no response body', EMPTY_RESPONSE_CODE);
535
645
  }
@@ -560,6 +670,12 @@ export class GrokAdapter extends LlmAdapter {
560
670
  ...options.reasoningEffort !== undefined
561
671
  ? { reasoning: { effort: String(options.reasoningEffort) } }
562
672
  : {},
673
+ // Cache-affinity hint (mirrors codex): xAI caches prompts per server,
674
+ // and `prompt_cache_key` is the Responses-API signal that routes repeat
675
+ // requests back to the cache-holding shard — without it every turn's
676
+ // cache hit is shard-routing luck. The session id is the stable key
677
+ // xAI's own docs recommend.
678
+ ...options.sessionId !== undefined ? { prompt_cache_key: String(options.sessionId) } : {},
563
679
  store: false,
564
680
  stream: true,
565
681
  };