dsh-plugin-subscriptions 0.5.3 → 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 (44) hide show
  1. package/README.md +45 -6
  2. package/README.zh.md +43 -4
  3. package/lib/auth/rpc.d.ts +36 -2
  4. package/lib/auth/rpc.js +47 -5
  5. package/lib/client/ImageGenerateToolview.d.ts +1 -1
  6. package/lib/client/SpeedSelect.d.ts +25 -2
  7. package/lib/client/SpeedSelect.js +10 -6
  8. package/lib/client/SubscriptionsSection.d.ts +74 -0
  9. package/lib/client/SubscriptionsSection.js +325 -4
  10. package/lib/client/VideoGenerateToolview.d.ts +1 -1
  11. package/lib/client/index.d.ts +1 -9
  12. package/lib/client/index.js +7 -4
  13. package/lib/client/locales.d.ts +28 -0
  14. package/lib/client/locales.js +28 -0
  15. package/lib/client.js +458 -10
  16. package/lib/client.js.map +1 -1
  17. package/lib/compat.d.ts +36 -0
  18. package/lib/compat.js +20 -0
  19. package/lib/index.d.ts +5 -1
  20. package/lib/index.js +865 -111
  21. package/lib/model-defaults.d.ts +23 -0
  22. package/lib/model-defaults.js +237 -0
  23. package/lib/providers/claude.d.ts +24 -3
  24. package/lib/providers/claude.js +35 -24
  25. package/lib/providers/codex.d.ts +21 -0
  26. package/lib/providers/codex.js +37 -10
  27. package/lib/providers/common.d.ts +70 -6
  28. package/lib/providers/common.js +118 -19
  29. package/lib/providers/copilot.d.ts +10 -0
  30. package/lib/providers/copilot.js +21 -8
  31. package/lib/providers/grok.d.ts +21 -0
  32. package/lib/providers/grok.js +37 -7
  33. package/lib/providers/pool-usage.d.ts +23 -2
  34. package/lib/providers/pool-usage.js +70 -15
  35. package/lib/providers/rate-limit.d.ts +192 -0
  36. package/lib/providers/rate-limit.js +338 -0
  37. package/lib/translate/anthropic.js +5 -4
  38. package/lib/translate/chat-completions.js +5 -4
  39. package/lib/translate/responses.js +5 -4
  40. package/package.json +21 -21
  41. package/lib/providers/antigravity.d.ts +0 -90
  42. package/lib/providers/antigravity.js +0 -392
  43. package/lib/translate/antigravity.d.ts +0 -110
  44. package/lib/translate/antigravity.js +0 -303
@@ -5,8 +5,8 @@
5
5
  * Concurrent refreshes for one provider coalesce behind a single in-flight
6
6
  * promise (`inflight`), so a rotating refresh token is never spent twice.
7
7
  */
8
- import { LlmError } from '@deepseek-ai/dsh-llm';
9
- import type { ReasoningEffortId } from '@deepseek-ai/dsh-llm';
8
+ import { LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
9
+ import type { RateLimitResetReader } from './rate-limit.js';
10
10
  /** One configured model catalog entry. */
11
11
  export interface ModelEntry {
12
12
  /** Wire model id; must be non-empty. */
@@ -33,14 +33,34 @@ export interface ModelEntry {
33
33
  * @returns the validated entries.
34
34
  */
35
35
  export declare function validateModels(models: readonly ModelEntry[], label: string): ModelEntry[];
36
+ /** Optional per-call hooks {@link httpLlmError} uses to read a rate-limit window. */
37
+ export interface HttpLlmErrorOptions {
38
+ /**
39
+ * The calling provider's reader for the instant its rate-limit window
40
+ * reopens. Consulted on a 429 only, and there ahead of the generic
41
+ * `retry-after` header, because a provider's own field names the window while
42
+ * `retry-after` often names a short backoff.
43
+ */
44
+ rateLimitReset?: RateLimitResetReader;
45
+ /** Diagnostic sink for a 429 that disclosed no reset instant this code recognizes. */
46
+ onWarn?: (message: string) => void;
47
+ }
36
48
  /**
37
- * Build an LlmError from a non-2xx provider response, reading and truncating
38
- * the body for the message and mapping the status to a stable code.
49
+ * Build an LlmError from a non-2xx provider response, mapping the status to a
50
+ * stable code and, for a rate-limited request, the disclosed reset instant to
51
+ * the `providerRetryAfterMs` the retry plugin waits out.
52
+ *
53
+ * A 429 classifies as `RATE_LIMIT` on the strength of the status alone, ahead
54
+ * of the quota-wording check. On these routes there is no terminal quota to
55
+ * distinguish: a subscription has no balance to top up, only a window that
56
+ * reopens, and providers announce an exhausted window with wording
57
+ * (`usage_limit_reached`) the shared classifier reads as permanent.
39
58
  * @param response - the failed response.
40
59
  * @param label - diagnostic prefix naming the provider API.
60
+ * @param options - the calling provider's rate-limit reader and warning sink.
41
61
  * @returns the classified error.
42
62
  */
43
- export declare function httpLlmError(response: Response, label: string): Promise<LlmError>;
63
+ export declare function httpLlmError(response: Response, label: string, options?: HttpLlmErrorOptions): Promise<LlmError>;
44
64
  /** An idle watchdog: aborts its signal when no SSE activity arrives within the timeout. */
45
65
  export interface IdleWatchdog {
46
66
  /** Signal to pass to fetch and body reads; aborts on caller cancel or idle expiry. */
@@ -76,7 +96,15 @@ export declare class OAuthEndpointError extends Error {
76
96
  readonly status: number;
77
97
  /** The provider's OAuth `error` code (e.g. `invalid_grant`), when present. */
78
98
  readonly oauthCode: string | undefined;
79
- constructor(message: string, status: number, oauthCode?: string);
99
+ /**
100
+ * The endpoint's `retry-after`, in ms, when it sent one. Usage/models
101
+ * endpoints reuse this error type and can rate-limit progressively (each
102
+ * hit within the window extends the next one), so a caller retrying on a
103
+ * fixed schedule instead of honoring this can keep an account locked out
104
+ * indefinitely.
105
+ */
106
+ readonly retryAfterMs: number | undefined;
107
+ constructor(message: string, status: number, oauthCode?: string, retryAfterMs?: number);
80
108
  }
81
109
  /**
82
110
  * Read an OAuth JSON error body into an {@link OAuthEndpointError}.
@@ -203,6 +231,42 @@ export interface DiscoveredModel {
203
231
  */
204
232
  copilotResponses?: boolean;
205
233
  }
234
+ /** Display name for a wire reasoning-effort identifier. */
235
+ export declare function effortDisplayName(effort: string): string;
236
+ /** The reasoning-block shape every caller passes to {@link mergeReasoning}. */
237
+ export interface ReasoningBlock {
238
+ efforts: readonly {
239
+ id: ReasoningEffortId;
240
+ name: string;
241
+ description?: string;
242
+ }[];
243
+ defaultEffort?: ReasoningEffortId;
244
+ }
245
+ /**
246
+ * Fold a configured per-model default effort into a reasoning block, keeping
247
+ * the DSH runtime invariant `defaultEffort ∈ efforts` (the runtime rejects an
248
+ * unknown default with `INVALID_MODEL_REASONING`).
249
+ *
250
+ * A configured level the base set does not advertise is *dropped*, not
251
+ * appended: for claude/grok/copilot the base is the provider's live catalog,
252
+ * i.e. the truth about what the model accepts, so honouring a stale override
253
+ * would put an unsupported effort on every single request instead of letting
254
+ * the harness reject it before provider I/O. The override then simply falls
255
+ * back to the provider's own default until the user picks a level the catalog
256
+ * still lists.
257
+ *
258
+ * `extendable` opts into the opposite rule for a base that is a *built-in
259
+ * fallback* rather than discovered truth (codex, whose static effort list is
260
+ * known to trail the backend): there, appending the configured level is how a
261
+ * newly shipped tier becomes selectable at all.
262
+ * @param configuredDefault - the user-configured default effort id, or undefined.
263
+ * @param base - the discovered/built-in reasoning block, or undefined.
264
+ * @param options - `extendable` marks the base as a fallback that may be extended.
265
+ * @returns the merged block, or undefined when neither side contributes one.
266
+ */
267
+ export declare function mergeReasoning(configuredDefault: string | undefined, base: ReasoningBlock | undefined, options?: {
268
+ extendable?: boolean;
269
+ }): DiscoveredModel['reasoning'] | undefined;
206
270
  /**
207
271
  * First account catalog that lists `model` (callers pass default-first).
208
272
  * One failing lookup sits that account out so a sibling's metadata still
@@ -5,7 +5,8 @@
5
5
  * Concurrent refreshes for one provider coalesce behind a single in-flight
6
6
  * promise (`inflight`), so a rotating refresh token is never spent twice.
7
7
  */
8
- import { CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmError, QUOTA_EXCEEDED_CODE, } from '@deepseek-ai/dsh-llm';
8
+ import { CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmError, QUOTA_EXCEEDED_CODE, ReasoningEffortId, } from '@deepseek-ai/dsh-llm';
9
+ import { rateLimitDiagnostics, retryAfterInstant, waitFromReset } from './rate-limit.js';
9
10
  /**
10
11
  * Validate a configured model catalog (mirrors llm-deepseek's resolveModels).
11
12
  * @param models - raw configured entries.
@@ -49,31 +50,41 @@ export function validateModels(models, label) {
49
50
  });
50
51
  }
51
52
  /**
52
- * Build an LlmError from a non-2xx provider response, reading and truncating
53
- * the body for the message and mapping the status to a stable code.
53
+ * Build an LlmError from a non-2xx provider response, mapping the status to a
54
+ * stable code and, for a rate-limited request, the disclosed reset instant to
55
+ * the `providerRetryAfterMs` the retry plugin waits out.
56
+ *
57
+ * A 429 classifies as `RATE_LIMIT` on the strength of the status alone, ahead
58
+ * of the quota-wording check. On these routes there is no terminal quota to
59
+ * distinguish: a subscription has no balance to top up, only a window that
60
+ * reopens, and providers announce an exhausted window with wording
61
+ * (`usage_limit_reached`) the shared classifier reads as permanent.
54
62
  * @param response - the failed response.
55
63
  * @param label - diagnostic prefix naming the provider API.
64
+ * @param options - the calling provider's rate-limit reader and warning sink.
56
65
  * @returns the classified error.
57
66
  */
58
- export async function httpLlmError(response, label) {
67
+ export async function httpLlmError(response, label, options = {}) {
59
68
  let body = '';
60
69
  try {
61
- body = (await response.text()).slice(0, 500);
70
+ body = await response.text();
62
71
  }
63
72
  catch {
64
73
  // Only swallow error-body reading: the HTTP status still identifies the failure.
65
74
  }
66
- const message = body.length > 0
67
- ? `${label} error (HTTP ${String(response.status)}): ${body}`
75
+ // Truncated for display only; the readers below need the whole body to parse it.
76
+ const shown = body.slice(0, 500);
77
+ const message = shown.length > 0
78
+ ? `${label} error (HTTP ${String(response.status)}): ${shown}`
68
79
  : `${label} error (HTTP ${String(response.status)})`;
69
80
  let code;
70
81
  if (response.status === 401 || response.status === 403)
71
82
  code = 'AUTH';
72
- else if (isQuotaExceededError(body))
73
- code = QUOTA_EXCEEDED_CODE;
74
83
  else if (response.status === 429)
75
84
  code = 'RATE_LIMIT';
76
- else if (response.status === 400 && isContextWindowExceededError(body))
85
+ else if (isQuotaExceededError(shown))
86
+ code = QUOTA_EXCEEDED_CODE;
87
+ else if (response.status === 400 && isContextWindowExceededError(shown))
77
88
  code = CONTEXT_WINDOW_EXCEEDED_CODE;
78
89
  else if (response.status === 408 || response.status === 504)
79
90
  code = 'TIMEOUT';
@@ -81,18 +92,43 @@ export async function httpLlmError(response, label) {
81
92
  code = 'SERVER';
82
93
  else
83
94
  code = `HTTP_${String(response.status)}`;
84
- const retryAfter = response.headers.get('retry-after');
85
- let providerRetryAfterMs;
86
- if (retryAfter !== null) {
87
- const seconds = Number(retryAfter);
88
- if (Number.isFinite(seconds) && seconds > 0)
89
- providerRetryAfterMs = seconds * 1000;
95
+ const now = Date.now();
96
+ // The provider's reader runs on a 429 and nowhere else. Providers attach
97
+ // their rate-limit headers to every response, so reading them on a transient
98
+ // 500 would report the current window's rollover — hours out — as the delay
99
+ // before retrying a failure that has nothing to do with the window, and the
100
+ // retry plugin honours `providerRetryAfterMs` for every retryable code.
101
+ // `retry-after` stays readable on any status: there it is a real backoff the
102
+ // provider asked for (a 503 shedding load), not a window snapshot.
103
+ //
104
+ // On a 429 the provider's own field wins outright rather than being raced
105
+ // against `retry-after`: a rejected window often carries both, and the
106
+ // generic header then names a short backoff that would burn the retry budget
107
+ // re-hitting the same closed window.
108
+ const rateLimited = response.status === 429;
109
+ const reset = rateLimited
110
+ ? options.rateLimitReset?.(response, body, now) ?? retryAfterInstant(response, now)
111
+ : retryAfterInstant(response, now);
112
+ if (reset === undefined && rateLimited) {
113
+ options.onWarn?.(`${label}: ${rateLimitDiagnostics(response, body)}`);
90
114
  }
91
115
  return new LlmError(message, code, {
92
116
  status: response.status,
93
- ...providerRetryAfterMs === undefined ? {} : { providerRetryAfterMs },
117
+ ...reset === undefined ? {} : { providerRetryAfterMs: waitFromReset(reset, now) },
94
118
  });
95
119
  }
120
+ /**
121
+ * Parse a response's `retry-after` header (seconds) into milliseconds.
122
+ * @param response - the failed response.
123
+ * @returns the delay in ms, or undefined when absent/unusable.
124
+ */
125
+ function parseRetryAfterMs(response) {
126
+ const retryAfter = response.headers.get('retry-after');
127
+ if (retryAfter === null)
128
+ return undefined;
129
+ const seconds = Number(retryAfter);
130
+ return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : undefined;
131
+ }
96
132
  /**
97
133
  * Create an idle watchdog chained to the caller's signal.
98
134
  * @param caller - the request's own abort signal, when present.
@@ -154,11 +190,20 @@ export class OAuthEndpointError extends Error {
154
190
  status;
155
191
  /** The provider's OAuth `error` code (e.g. `invalid_grant`), when present. */
156
192
  oauthCode;
157
- constructor(message, status, oauthCode) {
193
+ /**
194
+ * The endpoint's `retry-after`, in ms, when it sent one. Usage/models
195
+ * endpoints reuse this error type and can rate-limit progressively (each
196
+ * hit within the window extends the next one), so a caller retrying on a
197
+ * fixed schedule instead of honoring this can keep an account locked out
198
+ * indefinitely.
199
+ */
200
+ retryAfterMs;
201
+ constructor(message, status, oauthCode, retryAfterMs) {
158
202
  super(message);
159
203
  this.name = 'OAuthEndpointError';
160
204
  this.status = status;
161
205
  this.oauthCode = oauthCode;
206
+ this.retryAfterMs = retryAfterMs;
162
207
  }
163
208
  }
164
209
  /**
@@ -181,7 +226,7 @@ export async function oauthEndpointError(response, label) {
181
226
  const message = detail.length > 0
182
227
  ? `${label} token endpoint error (HTTP ${String(response.status)}): ${detail}`
183
228
  : `${label} token endpoint error (HTTP ${String(response.status)})`;
184
- return new OAuthEndpointError(message, response.status, oauthCode);
229
+ return new OAuthEndpointError(message, response.status, oauthCode, parseRetryAfterMs(response));
185
230
  }
186
231
  /**
187
232
  * Per-provider session freshness: loads the stored session, refreshes
@@ -286,6 +331,60 @@ export function withTimeout(work, timeoutMs) {
286
331
  aborted,
287
332
  ]);
288
333
  }
334
+ /** Display name for a wire reasoning-effort identifier. */
335
+ export function effortDisplayName(effort) {
336
+ return effort === 'xhigh' ? 'Extra High' : effort.charAt(0).toUpperCase() + effort.slice(1);
337
+ }
338
+ /**
339
+ * Fold a configured per-model default effort into a reasoning block, keeping
340
+ * the DSH runtime invariant `defaultEffort ∈ efforts` (the runtime rejects an
341
+ * unknown default with `INVALID_MODEL_REASONING`).
342
+ *
343
+ * A configured level the base set does not advertise is *dropped*, not
344
+ * appended: for claude/grok/copilot the base is the provider's live catalog,
345
+ * i.e. the truth about what the model accepts, so honouring a stale override
346
+ * would put an unsupported effort on every single request instead of letting
347
+ * the harness reject it before provider I/O. The override then simply falls
348
+ * back to the provider's own default until the user picks a level the catalog
349
+ * still lists.
350
+ *
351
+ * `extendable` opts into the opposite rule for a base that is a *built-in
352
+ * fallback* rather than discovered truth (codex, whose static effort list is
353
+ * known to trail the backend): there, appending the configured level is how a
354
+ * newly shipped tier becomes selectable at all.
355
+ * @param configuredDefault - the user-configured default effort id, or undefined.
356
+ * @param base - the discovered/built-in reasoning block, or undefined.
357
+ * @param options - `extendable` marks the base as a fallback that may be extended.
358
+ * @returns the merged block, or undefined when neither side contributes one.
359
+ */
360
+ export function mergeReasoning(configuredDefault, base, options) {
361
+ const detached = base === undefined
362
+ ? undefined
363
+ : {
364
+ efforts: [...base.efforts],
365
+ ...(base.defaultEffort === undefined ? {} : { defaultEffort: base.defaultEffort }),
366
+ };
367
+ if (configuredDefault === undefined)
368
+ return detached;
369
+ const effort = ReasoningEffortId(configuredDefault);
370
+ if (base === undefined) {
371
+ // No capability information at all (catalog unavailable, or a model the
372
+ // catalog does not cover). Inventing a reasoning block here would claim a
373
+ // capability nobody advertised; only a fallback-based provider may.
374
+ return options?.extendable === true
375
+ ? { efforts: [{ id: effort, name: effortDisplayName(effort) }], defaultEffort: effort }
376
+ : undefined;
377
+ }
378
+ if (base.efforts.some(entry => entry.id === effort)) {
379
+ return { efforts: [...base.efforts], defaultEffort: effort };
380
+ }
381
+ if (options?.extendable !== true)
382
+ return detached;
383
+ return {
384
+ efforts: [...base.efforts, { id: effort, name: effortDisplayName(effort) }],
385
+ defaultEffort: effort,
386
+ };
387
+ }
289
388
  /**
290
389
  * First account catalog that lists `model` (callers pass default-first).
291
390
  * One failing lookup sits that account out so a sibling's metadata still
@@ -22,6 +22,7 @@ import type { AttachmentStore } from '@deepseek-ai/dsh-attachment';
22
22
  import type { ReasoningReplayItem, ResponsesRequestInput, ResponsesStreamEvent } from '../translate/responses.js';
23
23
  import { AccountTokenManager } from './accounts.js';
24
24
  import type { CatalogPersistence, DiscoveredModel, FetchFn, ModelEntry } from './common.js';
25
+ import type { RateLimitWait } from './rate-limit.js';
25
26
  /**
26
27
  * Client id of the VS Code Copilot Chat GitHub App (pi-mono and
27
28
  * copilot2api-go use the same value): the app is pre-authorized for the
@@ -230,6 +231,14 @@ export interface CopilotAdapterOptions {
230
231
  resolveAttachments?: () => AttachmentStore | undefined;
231
232
  /** Durable catalog store seeding capability metadata across restarts. */
232
233
  catalogStore?: CatalogPersistence;
234
+ /** How long this route may hold a turn open waiting for a rate-limit window; defaults to waiting on, six-hour ceiling. */
235
+ rateLimit?: RateLimitWait;
236
+ /**
237
+ * Per-model default reasoning effort override (the Settings page's picker).
238
+ * Returns the user-configured default for one model, or undefined to follow
239
+ * the provider's own default.
240
+ */
241
+ defaultEffortOf?: (model: string) => string | undefined;
233
242
  }
234
243
  /** Copilot wire adapter: one instance serves the `copilot` provider route. */
235
244
  export declare class CopilotAdapter extends LlmAdapter {
@@ -263,6 +272,7 @@ export declare class CopilotAdapter extends LlmAdapter {
263
272
  /** Persisted cache for the default account; a throwaway cache for any other. */
264
273
  private catalogFor;
265
274
  providerInfo(provider: string): LlmProviderInfo;
275
+ providerRetryPolicy(provider: string): import("@deepseek-ai/dsh-llm").ResolvedRetryPolicy;
266
276
  private staticModels;
267
277
  listModels(provider: string): Promise<readonly LlmModelInfo[]>;
268
278
  /** The provider's own catalog: union of every account, or one account when named. */
@@ -17,9 +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, discoverAcrossAccounts, discoverOrRetryAuth, isDiscoveryAborted, isMissingOrInvalidCredential, oauthEndpointError, OAuthEndpointError, } from './common.js';
20
+ import { httpLlmError, idleWatchdog, mapFetchFailure, mergeReasoning, ModelCatalogCache, discoverAcrossAccounts, discoverOrRetryAuth, isDiscoveryAborted, isMissingOrInvalidCredential, oauthEndpointError, OAuthEndpointError, } from './common.js';
21
21
  import { AccountTokenManager, DISCOVERY_TIMEOUT_MS, unionAccountCatalogs } from './accounts.js';
22
22
  import { proxiedFetch } from '../http.js';
23
+ import { DEFAULT_RATE_LIMIT_WAIT, DEFAULT_RETRY, subscriptionRetryPolicy, } from './rate-limit.js';
23
24
  /**
24
25
  * Client id of the VS Code Copilot Chat GitHub App (pi-mono and
25
26
  * copilot2api-go use the same value): the app is pre-authorized for the
@@ -574,6 +575,9 @@ export class CopilotAdapter extends LlmAdapter {
574
575
  providerInfo(provider) {
575
576
  return { id: provider, name: 'GitHub Copilot' };
576
577
  }
578
+ providerRetryPolicy(provider) {
579
+ return subscriptionRetryPolicy(DEFAULT_RETRY, this.options.rateLimit ?? DEFAULT_RATE_LIMIT_WAIT, `copilot: provider "${provider}" retryPolicy`);
580
+ }
577
581
  staticModels(provider) {
578
582
  return this.options.models.map(model => ({
579
583
  provider,
@@ -768,6 +772,13 @@ export class CopilotAdapter extends LlmAdapter {
768
772
  async resolveOwnModel(provider, model) {
769
773
  const discovered = await this.discovered(model);
770
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);
771
782
  return {
772
783
  provider,
773
784
  id: model,
@@ -776,11 +787,7 @@ export class CopilotAdapter extends LlmAdapter {
776
787
  inputModalities: discovered?.inputModalities ?? configured?.inputModalities ?? ['text'],
777
788
  context: { contextWindow: discovered?.contextWindow ?? configured?.contextWindow ?? COPILOT_CONTEXT_WINDOW },
778
789
  defaultMaxTokens: configured?.maxTokens ?? COPILOT_DEFAULT_MAX_TOKENS,
779
- // Efforts come from the discovered catalog's reasoning_effort array; a
780
- // model that did not advertise one exposes none, so the harness rejects
781
- // an explicit effort before provider I/O instead of the API 400ing
782
- // (Copilot returns invalid_request_body for models that cannot reason).
783
- ...discovered?.reasoning === undefined ? {} : { reasoning: discovered.reasoning },
790
+ ...reasoning === undefined ? {} : { reasoning },
784
791
  };
785
792
  }
786
793
  async *stream(options) {
@@ -819,8 +826,14 @@ export class CopilotAdapter extends LlmAdapter {
819
826
  session = await this.options.tokens.session(account, true);
820
827
  response = await this.request(options, session, watchdog.signal, wire, scope);
821
828
  }
822
- if (!response.ok)
823
- 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
+ }
824
837
  if (response.body === null) {
825
838
  throw new LlmError('copilot API returned no response body', EMPTY_RESPONSE_CODE);
826
839
  }
@@ -11,11 +11,23 @@ import type { PoolAdapter } from './pool.js';
11
11
  import type { AttachmentStore } from '@deepseek-ai/dsh-attachment';
12
12
  import { AccountTokenManager } from './accounts.js';
13
13
  import type { CatalogPersistence, DiscoveredModel, FetchFn, ModelEntry, ProviderUsage } from './common.js';
14
+ import type { RateLimitResetReader, RateLimitWait } from './rate-limit.js';
14
15
  export declare const GROK_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
15
16
  export declare const GROK_DISCOVERY_URL = "https://auth.x.ai/.well-known/openid-configuration";
16
17
  export declare const GROK_API_URL = "https://api.x.ai/v1/responses";
17
18
  /** Refresh when the access token has less than this much life left. */
18
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;
19
31
  /** Discovered OIDC endpoints for the xAI authorization server. */
20
32
  export interface GrokDiscovery {
21
33
  authorizationEndpoint: string;
@@ -132,6 +144,14 @@ export interface GrokAdapterOptions {
132
144
  resolveAttachments?: () => AttachmentStore | undefined;
133
145
  /** Durable catalog store seeding capability metadata across restarts. */
134
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;
135
155
  }
136
156
  /** Grok wire adapter: one instance serves the `grok` provider route. */
137
157
  export declare class GrokAdapter extends LlmAdapter {
@@ -150,6 +170,7 @@ export declare class GrokAdapter extends LlmAdapter {
150
170
  private catalogFor;
151
171
  private listed;
152
172
  providerInfo(provider: string): LlmProviderInfo;
173
+ providerRetryPolicy(provider: string): import("@deepseek-ai/dsh-llm").ResolvedRetryPolicy;
153
174
  private staticModels;
154
175
  listModels(provider: string): Promise<readonly LlmModelInfo[]>;
155
176
  /** The provider's own catalog: union of every account, or one account when named. */
@@ -7,9 +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, discoverAcrossAccounts, discoverOrRetryAuth, isDiscoveryAborted, isMissingOrInvalidCredential, oauthEndpointError, OAuthEndpointError, } from './common.js';
10
+ import { httpLlmError, idleWatchdog, mapFetchFailure, mergeReasoning, ModelCatalogCache, discoverAcrossAccounts, discoverOrRetryAuth, isDiscoveryAborted, isMissingOrInvalidCredential, oauthEndpointError, OAuthEndpointError, } from './common.js';
11
11
  import { AccountTokenManager, DISCOVERY_TIMEOUT_MS, unionAccountCatalogs } from './accounts.js';
12
12
  import { proxiedFetch } from '../http.js';
13
+ import { DEFAULT_RATE_LIMIT_WAIT, DEFAULT_RETRY, jsonBody, resetFromFields, subscriptionRetryPolicy, } from './rate-limit.js';
13
14
  export const GROK_CLIENT_ID = 'b1a00492-073a-47ea-816f-4c329264a828';
14
15
  export const GROK_DISCOVERY_URL = 'https://auth.x.ai/.well-known/openid-configuration';
15
16
  export const GROK_API_URL = 'https://api.x.ai/v1/responses';
@@ -19,6 +20,19 @@ const GROK_CONTEXT_WINDOW = 256_000;
19
20
  const GROK_DEFAULT_MAX_TOKENS = 32_000;
20
21
  /** Refresh when the access token has less than this much life left. */
21
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);
22
36
  /** A discovered URL must be https on x.ai or a subdomain; anything else is a hostile document. */
23
37
  function assertXaiEndpoint(url, field) {
24
38
  let parsed;
@@ -500,6 +514,9 @@ export class GrokAdapter extends LlmAdapter {
500
514
  providerInfo(provider) {
501
515
  return { id: provider, name: 'Grok (Subscription)' };
502
516
  }
517
+ providerRetryPolicy(provider) {
518
+ return subscriptionRetryPolicy(DEFAULT_RETRY, this.options.rateLimit ?? DEFAULT_RATE_LIMIT_WAIT, `grok: provider "${provider}" retryPolicy`);
519
+ }
503
520
  staticModels(provider) {
504
521
  return this.options.models.map(model => ({
505
522
  provider,
@@ -578,6 +595,12 @@ export class GrokAdapter extends LlmAdapter {
578
595
  async resolveOwnModel(provider, model) {
579
596
  const discovered = await this.discovered(model);
580
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);
581
604
  return {
582
605
  provider,
583
606
  id: model,
@@ -586,10 +609,7 @@ export class GrokAdapter extends LlmAdapter {
586
609
  inputModalities: configured?.inputModalities ?? grokModalities(model),
587
610
  context: { contextWindow: discovered?.contextWindow ?? configured?.contextWindow ?? GROK_CONTEXT_WINDOW },
588
611
  defaultMaxTokens: configured?.maxTokens ?? GROK_DEFAULT_MAX_TOKENS,
589
- // Efforts come from the discovered CLI catalog; models it does not
590
- // cover expose none, so the harness rejects explicit efforts before
591
- // provider I/O instead of the API 400ing.
592
- ...discovered?.reasoning === undefined ? {} : { reasoning: discovered.reasoning },
612
+ ...reasoning === undefined ? {} : { reasoning },
593
613
  };
594
614
  }
595
615
  async *stream(options) {
@@ -614,8 +634,12 @@ export class GrokAdapter extends LlmAdapter {
614
634
  session = await this.options.tokens.session(account, true);
615
635
  response = await this.request(options, session, watchdog.signal);
616
636
  }
617
- if (!response.ok)
618
- 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
+ }
619
643
  if (response.body === null) {
620
644
  throw new LlmError('grok API returned no response body', EMPTY_RESPONSE_CODE);
621
645
  }
@@ -646,6 +670,12 @@ export class GrokAdapter extends LlmAdapter {
646
670
  ...options.reasoningEffort !== undefined
647
671
  ? { reasoning: { effort: String(options.reasoningEffort) } }
648
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) } : {},
649
679
  store: false,
650
680
  stream: true,
651
681
  };
@@ -43,14 +43,35 @@ export declare class PoolUsageTracker {
43
43
  /**
44
44
  * The quota view of one member. A cold cache awaits the first fetch; a
45
45
  * stale one answers immediately while the refresh serves the NEXT call
46
- * (member selection must never block on the network mid-conversation).
46
+ * (member selection must never block on the network mid-conversation). A
47
+ * failure still cooling down degrades immediately with no network call.
47
48
  * @param member - the pool member to score (account resolved).
48
49
  * @returns availability plus the urgency score.
49
50
  */
50
51
  quotaFor(member: ConcretePoolMember): Promise<MemberQuota>;
52
+ /**
53
+ * Same cache as {@link quotaFor}, for direct display (the Settings page):
54
+ * the raw snapshot, or the original fetch error, instead of a routing
55
+ * score.
56
+ * @param provider - the account's provider.
57
+ * @param account - the account key.
58
+ * @param force - bypass a fresh cached SNAPSHOT for an honest re-check (the
59
+ * manual Refresh button). A live failure cooldown is never bypassed —
60
+ * retrying through it is exactly what turns a 429 into a permanent
61
+ * lockout, so even a forced call still answers from the negative cache.
62
+ * @returns `{ supported: false }` when the provider has no usage fetcher.
63
+ */
64
+ snapshotFor(provider: ProviderId, account: string, force?: boolean): Promise<ProviderUsage>;
51
65
  /** Drop cached snapshots: one account, or a whole provider when `account` is omitted. */
52
66
  invalidate(provider: ProviderId, account?: string): void;
53
- /** Run (or join) the single in-flight fetch for one account key. */
67
+ /**
68
+ * Run (or join) the single in-flight fetch for one account key, caching
69
+ * either outcome. A missing/invalid credential is deliberately NOT
70
+ * negative-cached: it costs no network round trip (the session lookup
71
+ * fails before the request goes out) and re-checking live means the
72
+ * member rejoins routing the instant its login is fixed, rather than
73
+ * waiting out a stale cooldown.
74
+ */
54
75
  private refresh;
55
76
  /** Score one member against a snapshot's windows. */
56
77
  private score;