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
@@ -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}.
@@ -141,6 +169,13 @@ export declare class TokenManager<S extends TimedSession> {
141
169
  }
142
170
  /** Fetch signature adapters accept for discovery calls (injectable for tests). */
143
171
  export type FetchFn = typeof fetch;
172
+ /** Bound on one account catalog fetch or usage poll — a hang must not block the picker. */
173
+ export declare const DISCOVERY_TIMEOUT_MS = 10000;
174
+ /**
175
+ * Run `work` with an aborting signal. Resolves undefined when the timeout
176
+ * fires (the fetch is aborted); other failures propagate.
177
+ */
178
+ export declare function withTimeout<T>(work: (signal: AbortSignal) => Promise<T>, timeoutMs: number): Promise<T | undefined>;
144
179
  /** One rate-limit window reported by a provider's usage endpoint. */
145
180
  export interface UsageWindow {
146
181
  /** Window kind: `session` for the short rolling window, `weekly` for the 7-day one. */
@@ -196,6 +231,48 @@ export interface DiscoveredModel {
196
231
  */
197
232
  copilotResponses?: boolean;
198
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;
270
+ /**
271
+ * First account catalog that lists `model` (callers pass default-first).
272
+ * One failing lookup sits that account out so a sibling's metadata still
273
+ * resolves — the same isolation as the picker catalog union.
274
+ */
275
+ export declare function discoverAcrossAccounts(accounts: readonly string[], lookup: (account: string) => Promise<DiscoveredModel | undefined>): Promise<DiscoveredModel | undefined>;
199
276
  /** How long a discovered catalog is trusted before re-fetching. */
200
277
  export declare const DISCOVERY_TTL_MS: number;
201
278
  /** A durable snapshot of one provider's discovered catalog. */
@@ -234,6 +311,8 @@ export declare class ModelCatalogCache {
234
311
  private seeded;
235
312
  /** Set by {@link invalidate} so an in-flight disk read cannot resurrect dropped state. */
236
313
  private seedDisabled;
314
+ /** Bumped by {@link invalidate} so a loser in-flight fetch cannot write back. */
315
+ private generation;
237
316
  constructor(persistence?: CatalogPersistence | undefined, ttlMs?: number);
238
317
  /**
239
318
  * The cached catalog when fresh, without fetching.
@@ -272,6 +351,8 @@ export declare class ModelCatalogCache {
272
351
  }
273
352
  /** Whether discovery failed because the stored login is gone. */
274
353
  export declare function isMissingOrInvalidCredential(error: unknown): boolean;
354
+ /** Whether discovery stopped because the caller cancelled or the timeout fired. */
355
+ export declare function isDiscoveryAborted(error: unknown, signal?: AbortSignal): boolean;
275
356
  /**
276
357
  * Run a catalog fetch, retrying once after a forced token refresh when the
277
358
  * first attempt is a 401/AUTH. Only {@link ModelCatalogCache.invalidate}s
@@ -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
@@ -263,6 +308,101 @@ export class TokenManager {
263
308
  return next;
264
309
  }
265
310
  }
311
+ /** Bound on one account catalog fetch or usage poll — a hang must not block the picker. */
312
+ export const DISCOVERY_TIMEOUT_MS = 10_000;
313
+ /**
314
+ * Run `work` with an aborting signal. Resolves undefined when the timeout
315
+ * fires (the fetch is aborted); other failures propagate.
316
+ */
317
+ export function withTimeout(work, timeoutMs) {
318
+ const signal = AbortSignal.timeout(timeoutMs);
319
+ const aborted = new Promise(resolve => {
320
+ if (signal.aborted)
321
+ resolve(undefined);
322
+ else
323
+ signal.addEventListener('abort', () => resolve(undefined), { once: true });
324
+ });
325
+ return Promise.race([
326
+ work(signal).then(value => (signal.aborted ? undefined : value), (error) => {
327
+ if (signal.aborted)
328
+ return undefined;
329
+ throw error;
330
+ }),
331
+ aborted,
332
+ ]);
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
+ }
388
+ /**
389
+ * First account catalog that lists `model` (callers pass default-first).
390
+ * One failing lookup sits that account out so a sibling's metadata still
391
+ * resolves — the same isolation as the picker catalog union.
392
+ */
393
+ export async function discoverAcrossAccounts(accounts, lookup) {
394
+ for (const account of accounts) {
395
+ try {
396
+ const found = await lookup(account);
397
+ if (found !== undefined)
398
+ return found;
399
+ }
400
+ catch {
401
+ // sit out
402
+ }
403
+ }
404
+ return undefined;
405
+ }
266
406
  /** How long a discovered catalog is trusted before re-fetching. */
267
407
  export const DISCOVERY_TTL_MS = 5 * 60_000;
268
408
  /**
@@ -286,6 +426,8 @@ export class ModelCatalogCache {
286
426
  seeded;
287
427
  /** Set by {@link invalidate} so an in-flight disk read cannot resurrect dropped state. */
288
428
  seedDisabled = false;
429
+ /** Bumped by {@link invalidate} so a loser in-flight fetch cannot write back. */
430
+ generation = 0;
289
431
  constructor(persistence, ttlMs = DISCOVERY_TTL_MS) {
290
432
  this.persistence = persistence;
291
433
  this.ttlMs = ttlMs;
@@ -320,16 +462,25 @@ export class ModelCatalogCache {
320
462
  }
321
463
  /** Run (or join) the single in-flight fetch, updating memory and disk on success. */
322
464
  refresh(fetcher) {
323
- this.inflight ??= fetcher()
465
+ if (this.inflight !== undefined)
466
+ return this.inflight;
467
+ const gen = this.generation;
468
+ const pending = fetcher()
324
469
  .then((models) => {
470
+ if (this.generation !== gen)
471
+ return models;
325
472
  const snapshot = { at: Date.now(), models };
326
473
  this.entry = snapshot;
327
474
  // Write-through is fire-and-forget: a failed save only costs durability.
328
475
  void this.persistence?.save(snapshot).catch(() => undefined);
329
476
  return models;
330
477
  })
331
- .finally(() => { this.inflight = undefined; });
332
- return this.inflight;
478
+ .finally(() => {
479
+ if (this.generation === gen)
480
+ this.inflight = undefined;
481
+ });
482
+ this.inflight = pending;
483
+ return pending;
333
484
  }
334
485
  /**
335
486
  * Return the cached catalog when fresh, otherwise fetch and cache it.
@@ -370,7 +521,9 @@ export class ModelCatalogCache {
370
521
  }
371
522
  /** Drop the cached catalog (e.g. after a 401 proved the credential changed). */
372
523
  invalidate() {
524
+ this.generation += 1;
373
525
  this.entry = undefined;
526
+ this.inflight = undefined;
374
527
  this.seedDisabled = true;
375
528
  void this.persistence?.clear().catch(() => undefined);
376
529
  }
@@ -380,6 +533,16 @@ export function isMissingOrInvalidCredential(error) {
380
533
  return error instanceof LlmError
381
534
  && (error.code === 'MISSING_CREDENTIAL' || error.code === 'INVALID_CREDENTIAL');
382
535
  }
536
+ /** Whether discovery stopped because the caller cancelled or the timeout fired. */
537
+ export function isDiscoveryAborted(error, signal) {
538
+ if (signal?.aborted === true)
539
+ return true;
540
+ // Only treat abort-shaped errors as cancellation when this call had a signal;
541
+ // a refresh TimeoutError must not fail the whole picker union.
542
+ return signal !== undefined
543
+ && error instanceof Error
544
+ && (error.name === 'AbortError' || error.name === 'TimeoutError');
545
+ }
383
546
  /** Whether discovery failed because the access token was rejected. */
384
547
  function isDiscoveryAuthFailure(error) {
385
548
  return (error instanceof OAuthEndpointError && error.status === 401)
@@ -17,10 +17,12 @@ import { LlmAdapter } from '@deepseek-ai/dsh-llm';
17
17
  import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm';
18
18
  import type { DeviceFlowSpec } from '../auth/device-flow.js';
19
19
  import type { CopilotSession } from '../auth/store.js';
20
+ import type { PoolAdapter } from './pool.js';
20
21
  import type { AttachmentStore } from '@deepseek-ai/dsh-attachment';
21
22
  import type { ReasoningReplayItem, ResponsesRequestInput, ResponsesStreamEvent } from '../translate/responses.js';
22
- import { TokenManager } from './common.js';
23
+ import { AccountTokenManager } from './accounts.js';
23
24
  import type { CatalogPersistence, DiscoveredModel, FetchFn, ModelEntry } from './common.js';
25
+ import type { RateLimitWait } from './rate-limit.js';
24
26
  /**
25
27
  * Client id of the VS Code Copilot Chat GitHub App (pi-mono and
26
28
  * copilot2api-go use the same value): the app is pre-authorized for the
@@ -118,9 +120,10 @@ export declare function isCopilotPermanentRefreshError(error: unknown): boolean;
118
120
  * reasoning efforts (the endpoint discloses no default, so none is claimed).
119
121
  * @param session - the stored session (used as-is; never refreshed here).
120
122
  * @param fetchFn - fetch implementation (injectable for tests).
123
+ * @param signal - caller cancellation (pool-assembly timeout).
121
124
  * @returns discovered chat models in endpoint order.
122
125
  */
123
- export declare function fetchCopilotModels(session: CopilotSession, fetchFn?: FetchFn): Promise<DiscoveredModel[]>;
126
+ export declare function fetchCopilotModels(session: CopilotSession, fetchFn?: FetchFn, signal?: AbortSignal): Promise<DiscoveredModel[]>;
124
127
  /** Which upstream protocol one Copilot model speaks. */
125
128
  export type CopilotWire = 'chat-completions' | 'responses';
126
129
  /**
@@ -215,7 +218,9 @@ export declare class CopilotResponsesItemNormalizer {
215
218
  export interface CopilotAdapterOptions {
216
219
  models: readonly ModelEntry[];
217
220
  streamIdleTimeoutMs: number;
218
- tokens: TokenManager<CopilotSession>;
221
+ tokens: AccountTokenManager<CopilotSession>;
222
+ /** Late-bound pool facade (wired after adapter construction); pools list under their first member's provider. */
223
+ pool?: () => PoolAdapter | undefined;
219
224
  /** Whether to fetch the live catalog when logged in (false when config `models` overrides). */
220
225
  discovery: boolean;
221
226
  /** Warning sink for discovery failures that fall back to the static catalog. */
@@ -226,11 +231,23 @@ export interface CopilotAdapterOptions {
226
231
  resolveAttachments?: () => AttachmentStore | undefined;
227
232
  /** Durable catalog store seeding capability metadata across restarts. */
228
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;
229
242
  }
230
243
  /** Copilot wire adapter: one instance serves the `copilot` provider route. */
231
244
  export declare class CopilotAdapter extends LlmAdapter {
232
245
  private readonly options;
233
246
  private readonly catalog;
247
+ /** In-memory catalogs for non-default accounts (the persisted cache is the default's). */
248
+ private readonly accountCatalogs;
249
+ /** Account whose snapshot currently lives in {@link catalog}; cleared on default change. */
250
+ private catalogOwner;
234
251
  /**
235
252
  * [2026-08-23]-[a reasoning model continuing a tool chain must get its
236
253
  * reasoning back or it restarts from scratch every tool round trip; the
@@ -250,9 +267,16 @@ export declare class CopilotAdapter extends LlmAdapter {
250
267
  constructor(options: CopilotAdapterOptions);
251
268
  /** Discovery fetcher: resolves the session through the refresh-aware path. */
252
269
  private fetchCatalog;
270
+ /** Drop cached catalogs after login/logout so the next list does not reuse a stale plan. */
271
+ clearAccountCatalog(account?: string): void;
272
+ /** Persisted cache for the default account; a throwaway cache for any other. */
273
+ private catalogFor;
253
274
  providerInfo(provider: string): LlmProviderInfo;
275
+ providerRetryPolicy(provider: string): import("@deepseek-ai/dsh-llm").ResolvedRetryPolicy;
254
276
  private staticModels;
255
277
  listModels(provider: string): Promise<readonly LlmModelInfo[]>;
278
+ /** The provider's own catalog: union of every account, or one account when named. */
279
+ listOwnModels(provider: string, account?: string, signal?: AbortSignal): Promise<readonly LlmModelInfo[]>;
256
280
  /**
257
281
  * The discovered entry for one model. Resolved through the cache's
258
282
  * stale-while-revalidate path: capability metadata must stay stable across
@@ -309,7 +333,12 @@ export declare class CopilotAdapter extends LlmAdapter {
309
333
  */
310
334
  clearReplayState(): void;
311
335
  resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo>;
336
+ /** Capability resolution of the provider's own models (the pool resolves members here). */
337
+ resolveOwnModel(provider: string, model: string): Promise<LlmResolvedModelInfo>;
312
338
  stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
339
+ /** Pool seam: stream through one specific account instead of the default. */
340
+ streamAccount(options: GenerateOptions, account: string): AsyncIterable<StreamChunk>;
341
+ private streamCore;
313
342
  private request;
314
343
  }
315
344
  export {};