dsh-plugin-subscriptions 0.5.2 → 0.5.3

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 (41) hide show
  1. package/README.md +36 -1
  2. package/README.zh.md +36 -1
  3. package/lib/auth/rpc.d.ts +29 -12
  4. package/lib/auth/rpc.js +29 -6
  5. package/lib/auth/store.d.ts +75 -17
  6. package/lib/auth/store.js +148 -27
  7. package/lib/client/SubscriptionsSection.d.ts +9 -3
  8. package/lib/client/SubscriptionsSection.js +93 -65
  9. package/lib/client/locales.d.ts +18 -10
  10. package/lib/client/locales.js +18 -10
  11. package/lib/client.js +250 -127
  12. package/lib/client.js.map +1 -1
  13. package/lib/index.d.ts +21 -0
  14. package/lib/index.js +1482 -168
  15. package/lib/providers/accounts.d.ts +102 -0
  16. package/lib/providers/accounts.js +123 -0
  17. package/lib/providers/claude.d.ts +22 -4
  18. package/lib/providers/claude.js +91 -11
  19. package/lib/providers/codex.d.ts +24 -3
  20. package/lib/providers/codex.js +116 -17
  21. package/lib/providers/common.d.ts +17 -0
  22. package/lib/providers/common.js +67 -3
  23. package/lib/providers/copilot.d.ts +22 -3
  24. package/lib/providers/copilot.js +91 -12
  25. package/lib/providers/grok.d.ts +24 -4
  26. package/lib/providers/grok.js +100 -14
  27. package/lib/providers/pool-family.d.ts +56 -0
  28. package/lib/providers/pool-family.js +45 -0
  29. package/lib/providers/pool-health.d.ts +74 -0
  30. package/lib/providers/pool-health.js +148 -0
  31. package/lib/providers/pool-usage.d.ts +57 -0
  32. package/lib/providers/pool-usage.js +130 -0
  33. package/lib/providers/pool.d.ts +107 -0
  34. package/lib/providers/pool.js +371 -0
  35. package/lib/tools/image-generate.d.ts +3 -3
  36. package/lib/tools/image-generate.js +2 -1
  37. package/lib/tools/video-generate.d.ts +2 -2
  38. package/lib/tools/video-generate.js +2 -1
  39. package/lib/tools/x-search.d.ts +2 -2
  40. package/lib/tools/x-search.js +2 -1
  41. package/package.json +1 -1
@@ -8,7 +8,8 @@ import { attributionHeaders, EMPTY_RESPONSE_CODE, errorChain, LlmAdapter, LlmErr
8
8
  import { decodeJwtPayload } from '../auth/jwt.js';
9
9
  import { resolveImages } from '../translate/resolved.js';
10
10
  import { streamResponses, toResponsesInput, toResponsesTools } from '../translate/responses.js';
11
- import { httpLlmError, idleWatchdog, mapFetchFailure, ModelCatalogCache, discoverOrRetryAuth, isMissingOrInvalidCredential, oauthEndpointError, OAuthEndpointError, TokenManager, } from './common.js';
11
+ import { httpLlmError, idleWatchdog, mapFetchFailure, ModelCatalogCache, discoverAcrossAccounts, discoverOrRetryAuth, isDiscoveryAborted, isMissingOrInvalidCredential, oauthEndpointError, OAuthEndpointError, } from './common.js';
12
+ import { AccountTokenManager, DISCOVERY_TIMEOUT_MS, unionAccountCatalogs } from './accounts.js';
12
13
  import { proxiedFetch } from '../http.js';
13
14
  export const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
14
15
  export const CODEX_AUTHORIZE_URL = 'https://auth.openai.com/oauth/authorize';
@@ -308,9 +309,10 @@ function supportsFastTier(entry) {
308
309
  * Fetch the live codex model catalog with the session's auth headers.
309
310
  * @param session - the stored session (used as-is; never refreshed here).
310
311
  * @param fetchFn - fetch implementation (injectable for tests).
312
+ * @param signal - caller cancellation (pool-assembly timeout).
311
313
  * @returns discovered models: hidden entries dropped, sorted by priority.
312
314
  */
313
- export async function fetchCodexModels(session, fetchFn = proxiedFetch) {
315
+ export async function fetchCodexModels(session, fetchFn = proxiedFetch, signal) {
314
316
  const url = `${CODEX_MODELS_URL}?client_version=${CODEX_CLIENT_VERSION}`;
315
317
  const response = await fetchFn(url, {
316
318
  headers: {
@@ -320,6 +322,7 @@ export async function fetchCodexModels(session, fetchFn = proxiedFetch) {
320
322
  'accept': 'application/json',
321
323
  ...attributionHeaders(),
322
324
  },
325
+ ...signal === undefined ? {} : { signal },
323
326
  });
324
327
  if (!response.ok)
325
328
  throw await oauthEndpointError(response, 'codex models');
@@ -451,14 +454,47 @@ export function codexRequestBody(options, resolved, fast) {
451
454
  export class CodexAdapter extends LlmAdapter {
452
455
  options;
453
456
  catalog;
457
+ /** In-memory catalogs for non-default accounts (the persisted cache is the default's). */
458
+ accountCatalogs = new Map();
459
+ /** Account whose snapshot currently lives in {@link catalog}; cleared on default change. */
460
+ catalogOwner;
454
461
  constructor(options) {
455
462
  super();
456
463
  this.options = options;
457
464
  this.catalog = new ModelCatalogCache(options.catalogStore);
458
465
  }
459
466
  /** Discovery fetcher: resolves the session through the refresh-aware path. */
460
- async fetchCatalog() {
461
- return fetchCodexModels(await this.options.tokens.session(), this.options.fetchFn);
467
+ async fetchCatalog(account, signal) {
468
+ return fetchCodexModels(await this.options.tokens.session(account), this.options.fetchFn, signal);
469
+ }
470
+ /** Drop cached catalogs after login/logout so the next list does not reuse a stale plan. */
471
+ clearAccountCatalog(account) {
472
+ if (account === undefined)
473
+ this.accountCatalogs.clear();
474
+ else
475
+ this.accountCatalogs.delete(account);
476
+ if (account === undefined || this.catalogOwner === account || this.catalogOwner === undefined) {
477
+ this.catalogOwner = undefined;
478
+ this.catalog.invalidate();
479
+ }
480
+ }
481
+ /** Persisted cache for the default account; a throwaway cache for any other. */
482
+ async catalogFor(account) {
483
+ const defaultKey = await this.options.tokens.defaultAccount();
484
+ const key = account ?? defaultKey;
485
+ if (key === undefined || key === defaultKey) {
486
+ if (this.catalogOwner !== undefined && this.catalogOwner !== defaultKey) {
487
+ this.catalog.invalidate();
488
+ }
489
+ this.catalogOwner = defaultKey;
490
+ return this.catalog;
491
+ }
492
+ let cache = this.accountCatalogs.get(key);
493
+ if (cache === undefined) {
494
+ cache = new ModelCatalogCache();
495
+ this.accountCatalogs.set(key, cache);
496
+ }
497
+ return cache;
462
498
  }
463
499
  providerInfo(provider) {
464
500
  return { id: provider, name: 'ChatGPT (Codex)' };
@@ -472,26 +508,48 @@ export class CodexAdapter extends LlmAdapter {
472
508
  }));
473
509
  }
474
510
  async listModels(provider) {
475
- // Not logged in → empty catalog, so the web picker drops the provider.
476
- const session = await this.options.tokens.peek();
477
- if (session === undefined)
511
+ const own = await this.listOwnModels(provider);
512
+ const pool = this.options.pool?.();
513
+ if (pool === undefined)
514
+ return own;
515
+ const extra = await pool.modelsForProvider(provider);
516
+ const seen = new Set(own.map(model => model.id));
517
+ // Account pools reuse the catalog row; only configured tiers are extra.
518
+ return [...own, ...extra.filter(model => !seen.has(model.id))];
519
+ }
520
+ /** The provider's own catalog: union of every account, or one account when named. */
521
+ async listOwnModels(provider, account, signal) {
522
+ if (account === undefined) {
523
+ const accounts = (await this.options.tokens.list()).map(entry => entry.key);
524
+ if (accounts.length === 0)
525
+ return [];
526
+ return unionAccountCatalogs(accounts, (key, accountSignal) => this.listOwnModels(provider, key, accountSignal), { timeoutMs: this.options.discoveryTimeoutMs ?? DISCOVERY_TIMEOUT_MS, ...signal === undefined ? {} : { signal } });
527
+ }
528
+ if (!await this.options.tokens.hasSession(account)) {
478
529
  return [];
530
+ }
479
531
  if (!this.options.discovery)
480
532
  return this.staticModels(provider);
533
+ const catalog = await this.catalogFor(account);
481
534
  try {
482
535
  // The fetcher runs only on a cache miss, and resolves the session
483
536
  // through the refresh-aware path so an expired access token renews here
484
537
  // instead of failing discovery into the static fallback.
485
- const discovered = await discoverOrRetryAuth(force => this.options.tokens.session(force), this.catalog, () => this.catalog.get(() => this.fetchCatalog()));
538
+ const discovered = await discoverOrRetryAuth(force => this.options.tokens.session(account, force), catalog, () => catalog.get(() => this.fetchCatalog(account, signal)));
486
539
  return discovered.map(model => ({
487
540
  provider,
488
541
  id: model.id,
489
542
  name: model.name,
490
543
  ...model.description === undefined ? {} : { description: model.description },
491
544
  inputModalities: CODEX_MODALITIES,
545
+ ...model.priority === undefined ? {} : { priority: model.priority },
492
546
  }));
493
547
  }
494
548
  catch (error) {
549
+ // A cancelled discovery must not fall back to the static catalog — the
550
+ // caller (pool assembly) treats abort as "this account sits out".
551
+ if (isDiscoveryAborted(error, signal))
552
+ throw error;
495
553
  // A permanent refresh failure deletes the stored session: the provider
496
554
  // is logged out, so hide it instead of showing a stale static catalog.
497
555
  if (isMissingOrInvalidCredential(error))
@@ -510,8 +568,12 @@ export class CodexAdapter extends LlmAdapter {
510
568
  async discovered(model) {
511
569
  if (!this.options.discovery)
512
570
  return undefined;
513
- const models = await this.catalog.resolve(() => this.fetchCatalog());
514
- return models?.find(entry => entry.id === model);
571
+ const accounts = (await this.options.tokens.list()).map(entry => entry.key);
572
+ return discoverAcrossAccounts(accounts, async (account) => {
573
+ const catalog = await this.catalogFor(account);
574
+ const models = await catalog.resolve(() => this.fetchCatalog(account));
575
+ return models?.find(entry => entry.id === model);
576
+ });
515
577
  }
516
578
  /** Whether the discovered catalog advertises a fast tier for this model. */
517
579
  async supportsFastTier(model) {
@@ -522,14 +584,39 @@ export class CodexAdapter extends LlmAdapter {
522
584
  if (!this.options.discovery)
523
585
  return [];
524
586
  // Not logged in → no fast models, so the Speed toggle hides after logout
525
- // (mirrors the listModels guard above).
526
- const session = await this.options.tokens.peek();
527
- if (session === undefined)
587
+ // (mirrors the listModels guard above). Union every account: a fast-capable
588
+ // model only the non-default lists (e.g. gpt-5.6-sol) must still show Speed.
589
+ const accounts = (await this.options.tokens.list()).map(entry => entry.key);
590
+ if (accounts.length === 0)
528
591
  return [];
529
- const models = await this.catalog.resolve(() => this.fetchCatalog());
530
- return (models ?? []).filter(model => model.fastTier === true).map(model => model.id);
592
+ const seen = new Set();
593
+ const ids = [];
594
+ for (const account of accounts) {
595
+ try {
596
+ const catalog = await this.catalogFor(account);
597
+ const models = await catalog.resolve(() => this.fetchCatalog(account));
598
+ for (const model of models ?? []) {
599
+ if (model.fastTier !== true || seen.has(model.id))
600
+ continue;
601
+ seen.add(model.id);
602
+ ids.push(model.id);
603
+ }
604
+ }
605
+ catch {
606
+ // sit out
607
+ }
608
+ }
609
+ return ids;
531
610
  }
532
611
  async resolveModel(provider, model) {
612
+ const pool = this.options.pool?.();
613
+ if (pool !== undefined && await pool.owns(provider, model)) {
614
+ return pool.resolveModel(provider, model);
615
+ }
616
+ return this.resolveOwnModel(provider, model);
617
+ }
618
+ /** Capability resolution of the provider's own models (the pool resolves members here). */
619
+ async resolveOwnModel(provider, model) {
533
620
  // Discovered metadata (when discovery is on) wins over the static entry;
534
621
  // the static entry wins over the built-in defaults.
535
622
  const discovered = await this.discovered(model);
@@ -546,13 +633,25 @@ export class CodexAdapter extends LlmAdapter {
546
633
  };
547
634
  }
548
635
  async *stream(options) {
636
+ const pool = this.options.pool?.();
637
+ if (pool !== undefined && await pool.owns(options.provider, options.model)) {
638
+ yield* pool.stream(options);
639
+ return;
640
+ }
641
+ yield* this.streamCore(options);
642
+ }
643
+ /** Pool seam: stream through one specific account instead of the default. */
644
+ streamAccount(options, account) {
645
+ return this.streamCore(options, account);
646
+ }
647
+ async *streamCore(options, account) {
549
648
  const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
550
649
  try {
551
- let session = await this.options.tokens.session();
650
+ let session = await this.options.tokens.session(account);
552
651
  let response = await this.request(options, session, watchdog.signal);
553
652
  if (response.status === 401) {
554
653
  // One forced refresh + retry on an unexpired-but-rejected token.
555
- session = await this.options.tokens.session(true);
654
+ session = await this.options.tokens.session(account, true);
556
655
  response = await this.request(options, session, watchdog.signal);
557
656
  }
558
657
  if (!response.ok)
@@ -141,6 +141,13 @@ export declare class TokenManager<S extends TimedSession> {
141
141
  }
142
142
  /** Fetch signature adapters accept for discovery calls (injectable for tests). */
143
143
  export type FetchFn = typeof fetch;
144
+ /** Bound on one account catalog fetch or usage poll — a hang must not block the picker. */
145
+ export declare const DISCOVERY_TIMEOUT_MS = 10000;
146
+ /**
147
+ * Run `work` with an aborting signal. Resolves undefined when the timeout
148
+ * fires (the fetch is aborted); other failures propagate.
149
+ */
150
+ export declare function withTimeout<T>(work: (signal: AbortSignal) => Promise<T>, timeoutMs: number): Promise<T | undefined>;
144
151
  /** One rate-limit window reported by a provider's usage endpoint. */
145
152
  export interface UsageWindow {
146
153
  /** Window kind: `session` for the short rolling window, `weekly` for the 7-day one. */
@@ -196,6 +203,12 @@ export interface DiscoveredModel {
196
203
  */
197
204
  copilotResponses?: boolean;
198
205
  }
206
+ /**
207
+ * First account catalog that lists `model` (callers pass default-first).
208
+ * One failing lookup sits that account out so a sibling's metadata still
209
+ * resolves — the same isolation as the picker catalog union.
210
+ */
211
+ export declare function discoverAcrossAccounts(accounts: readonly string[], lookup: (account: string) => Promise<DiscoveredModel | undefined>): Promise<DiscoveredModel | undefined>;
199
212
  /** How long a discovered catalog is trusted before re-fetching. */
200
213
  export declare const DISCOVERY_TTL_MS: number;
201
214
  /** A durable snapshot of one provider's discovered catalog. */
@@ -234,6 +247,8 @@ export declare class ModelCatalogCache {
234
247
  private seeded;
235
248
  /** Set by {@link invalidate} so an in-flight disk read cannot resurrect dropped state. */
236
249
  private seedDisabled;
250
+ /** Bumped by {@link invalidate} so a loser in-flight fetch cannot write back. */
251
+ private generation;
237
252
  constructor(persistence?: CatalogPersistence | undefined, ttlMs?: number);
238
253
  /**
239
254
  * The cached catalog when fresh, without fetching.
@@ -272,6 +287,8 @@ export declare class ModelCatalogCache {
272
287
  }
273
288
  /** Whether discovery failed because the stored login is gone. */
274
289
  export declare function isMissingOrInvalidCredential(error: unknown): boolean;
290
+ /** Whether discovery stopped because the caller cancelled or the timeout fired. */
291
+ export declare function isDiscoveryAborted(error: unknown, signal?: AbortSignal): boolean;
275
292
  /**
276
293
  * Run a catalog fetch, retrying once after a forced token refresh when the
277
294
  * first attempt is a 401/AUTH. Only {@link ModelCatalogCache.invalidate}s
@@ -263,6 +263,47 @@ export class TokenManager {
263
263
  return next;
264
264
  }
265
265
  }
266
+ /** Bound on one account catalog fetch or usage poll — a hang must not block the picker. */
267
+ export const DISCOVERY_TIMEOUT_MS = 10_000;
268
+ /**
269
+ * Run `work` with an aborting signal. Resolves undefined when the timeout
270
+ * fires (the fetch is aborted); other failures propagate.
271
+ */
272
+ export function withTimeout(work, timeoutMs) {
273
+ const signal = AbortSignal.timeout(timeoutMs);
274
+ const aborted = new Promise(resolve => {
275
+ if (signal.aborted)
276
+ resolve(undefined);
277
+ else
278
+ signal.addEventListener('abort', () => resolve(undefined), { once: true });
279
+ });
280
+ return Promise.race([
281
+ work(signal).then(value => (signal.aborted ? undefined : value), (error) => {
282
+ if (signal.aborted)
283
+ return undefined;
284
+ throw error;
285
+ }),
286
+ aborted,
287
+ ]);
288
+ }
289
+ /**
290
+ * First account catalog that lists `model` (callers pass default-first).
291
+ * One failing lookup sits that account out so a sibling's metadata still
292
+ * resolves — the same isolation as the picker catalog union.
293
+ */
294
+ export async function discoverAcrossAccounts(accounts, lookup) {
295
+ for (const account of accounts) {
296
+ try {
297
+ const found = await lookup(account);
298
+ if (found !== undefined)
299
+ return found;
300
+ }
301
+ catch {
302
+ // sit out
303
+ }
304
+ }
305
+ return undefined;
306
+ }
266
307
  /** How long a discovered catalog is trusted before re-fetching. */
267
308
  export const DISCOVERY_TTL_MS = 5 * 60_000;
268
309
  /**
@@ -286,6 +327,8 @@ export class ModelCatalogCache {
286
327
  seeded;
287
328
  /** Set by {@link invalidate} so an in-flight disk read cannot resurrect dropped state. */
288
329
  seedDisabled = false;
330
+ /** Bumped by {@link invalidate} so a loser in-flight fetch cannot write back. */
331
+ generation = 0;
289
332
  constructor(persistence, ttlMs = DISCOVERY_TTL_MS) {
290
333
  this.persistence = persistence;
291
334
  this.ttlMs = ttlMs;
@@ -320,16 +363,25 @@ export class ModelCatalogCache {
320
363
  }
321
364
  /** Run (or join) the single in-flight fetch, updating memory and disk on success. */
322
365
  refresh(fetcher) {
323
- this.inflight ??= fetcher()
366
+ if (this.inflight !== undefined)
367
+ return this.inflight;
368
+ const gen = this.generation;
369
+ const pending = fetcher()
324
370
  .then((models) => {
371
+ if (this.generation !== gen)
372
+ return models;
325
373
  const snapshot = { at: Date.now(), models };
326
374
  this.entry = snapshot;
327
375
  // Write-through is fire-and-forget: a failed save only costs durability.
328
376
  void this.persistence?.save(snapshot).catch(() => undefined);
329
377
  return models;
330
378
  })
331
- .finally(() => { this.inflight = undefined; });
332
- return this.inflight;
379
+ .finally(() => {
380
+ if (this.generation === gen)
381
+ this.inflight = undefined;
382
+ });
383
+ this.inflight = pending;
384
+ return pending;
333
385
  }
334
386
  /**
335
387
  * Return the cached catalog when fresh, otherwise fetch and cache it.
@@ -370,7 +422,9 @@ export class ModelCatalogCache {
370
422
  }
371
423
  /** Drop the cached catalog (e.g. after a 401 proved the credential changed). */
372
424
  invalidate() {
425
+ this.generation += 1;
373
426
  this.entry = undefined;
427
+ this.inflight = undefined;
374
428
  this.seedDisabled = true;
375
429
  void this.persistence?.clear().catch(() => undefined);
376
430
  }
@@ -380,6 +434,16 @@ export function isMissingOrInvalidCredential(error) {
380
434
  return error instanceof LlmError
381
435
  && (error.code === 'MISSING_CREDENTIAL' || error.code === 'INVALID_CREDENTIAL');
382
436
  }
437
+ /** Whether discovery stopped because the caller cancelled or the timeout fired. */
438
+ export function isDiscoveryAborted(error, signal) {
439
+ if (signal?.aborted === true)
440
+ return true;
441
+ // Only treat abort-shaped errors as cancellation when this call had a signal;
442
+ // a refresh TimeoutError must not fail the whole picker union.
443
+ return signal !== undefined
444
+ && error instanceof Error
445
+ && (error.name === 'AbortError' || error.name === 'TimeoutError');
446
+ }
383
447
  /** Whether discovery failed because the access token was rejected. */
384
448
  function isDiscoveryAuthFailure(error) {
385
449
  return (error instanceof OAuthEndpointError && error.status === 401)
@@ -17,9 +17,10 @@ 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';
24
25
  /**
25
26
  * Client id of the VS Code Copilot Chat GitHub App (pi-mono and
@@ -118,9 +119,10 @@ export declare function isCopilotPermanentRefreshError(error: unknown): boolean;
118
119
  * reasoning efforts (the endpoint discloses no default, so none is claimed).
119
120
  * @param session - the stored session (used as-is; never refreshed here).
120
121
  * @param fetchFn - fetch implementation (injectable for tests).
122
+ * @param signal - caller cancellation (pool-assembly timeout).
121
123
  * @returns discovered chat models in endpoint order.
122
124
  */
123
- export declare function fetchCopilotModels(session: CopilotSession, fetchFn?: FetchFn): Promise<DiscoveredModel[]>;
125
+ export declare function fetchCopilotModels(session: CopilotSession, fetchFn?: FetchFn, signal?: AbortSignal): Promise<DiscoveredModel[]>;
124
126
  /** Which upstream protocol one Copilot model speaks. */
125
127
  export type CopilotWire = 'chat-completions' | 'responses';
126
128
  /**
@@ -215,7 +217,9 @@ export declare class CopilotResponsesItemNormalizer {
215
217
  export interface CopilotAdapterOptions {
216
218
  models: readonly ModelEntry[];
217
219
  streamIdleTimeoutMs: number;
218
- tokens: TokenManager<CopilotSession>;
220
+ tokens: AccountTokenManager<CopilotSession>;
221
+ /** Late-bound pool facade (wired after adapter construction); pools list under their first member's provider. */
222
+ pool?: () => PoolAdapter | undefined;
219
223
  /** Whether to fetch the live catalog when logged in (false when config `models` overrides). */
220
224
  discovery: boolean;
221
225
  /** Warning sink for discovery failures that fall back to the static catalog. */
@@ -231,6 +235,10 @@ export interface CopilotAdapterOptions {
231
235
  export declare class CopilotAdapter extends LlmAdapter {
232
236
  private readonly options;
233
237
  private readonly catalog;
238
+ /** In-memory catalogs for non-default accounts (the persisted cache is the default's). */
239
+ private readonly accountCatalogs;
240
+ /** Account whose snapshot currently lives in {@link catalog}; cleared on default change. */
241
+ private catalogOwner;
234
242
  /**
235
243
  * [2026-08-23]-[a reasoning model continuing a tool chain must get its
236
244
  * reasoning back or it restarts from scratch every tool round trip; the
@@ -250,9 +258,15 @@ export declare class CopilotAdapter extends LlmAdapter {
250
258
  constructor(options: CopilotAdapterOptions);
251
259
  /** Discovery fetcher: resolves the session through the refresh-aware path. */
252
260
  private fetchCatalog;
261
+ /** Drop cached catalogs after login/logout so the next list does not reuse a stale plan. */
262
+ clearAccountCatalog(account?: string): void;
263
+ /** Persisted cache for the default account; a throwaway cache for any other. */
264
+ private catalogFor;
253
265
  providerInfo(provider: string): LlmProviderInfo;
254
266
  private staticModels;
255
267
  listModels(provider: string): Promise<readonly LlmModelInfo[]>;
268
+ /** The provider's own catalog: union of every account, or one account when named. */
269
+ listOwnModels(provider: string, account?: string, signal?: AbortSignal): Promise<readonly LlmModelInfo[]>;
256
270
  /**
257
271
  * The discovered entry for one model. Resolved through the cache's
258
272
  * stale-while-revalidate path: capability metadata must stay stable across
@@ -309,7 +323,12 @@ export declare class CopilotAdapter extends LlmAdapter {
309
323
  */
310
324
  clearReplayState(): void;
311
325
  resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo>;
326
+ /** Capability resolution of the provider's own models (the pool resolves members here). */
327
+ resolveOwnModel(provider: string, model: string): Promise<LlmResolvedModelInfo>;
312
328
  stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
329
+ /** Pool seam: stream through one specific account instead of the default. */
330
+ streamAccount(options: GenerateOptions, account: string): AsyncIterable<StreamChunk>;
331
+ private streamCore;
313
332
  private request;
314
333
  }
315
334
  export {};
@@ -17,7 +17,8 @@ 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, 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';
22
23
  /**
23
24
  * Client id of the VS Code Copilot Chat GitHub App (pi-mono and
@@ -243,15 +244,17 @@ function copilotReasoning(entry) {
243
244
  * reasoning efforts (the endpoint discloses no default, so none is claimed).
244
245
  * @param session - the stored session (used as-is; never refreshed here).
245
246
  * @param fetchFn - fetch implementation (injectable for tests).
247
+ * @param signal - caller cancellation (pool-assembly timeout).
246
248
  * @returns discovered chat models in endpoint order.
247
249
  */
248
- export async function fetchCopilotModels(session, fetchFn = proxiedFetch) {
250
+ export async function fetchCopilotModels(session, fetchFn = proxiedFetch, signal) {
249
251
  const response = await fetchFn(COPILOT_MODELS_URL, {
250
252
  headers: {
251
253
  'authorization': `Bearer ${session.accessToken}`,
252
254
  'accept': 'application/json',
253
255
  ...copilotHeaders(false, await latestVsCodeVersion(fetchFn)),
254
256
  },
257
+ ...signal === undefined ? {} : { signal },
255
258
  });
256
259
  if (!response.ok)
257
260
  throw await oauthEndpointError(response, 'copilot models');
@@ -510,6 +513,10 @@ export class CopilotResponsesItemNormalizer {
510
513
  export class CopilotAdapter extends LlmAdapter {
511
514
  options;
512
515
  catalog;
516
+ /** In-memory catalogs for non-default accounts (the persisted cache is the default's). */
517
+ accountCatalogs = new Map();
518
+ /** Account whose snapshot currently lives in {@link catalog}; cleared on default change. */
519
+ catalogOwner;
513
520
  /**
514
521
  * [2026-08-23]-[a reasoning model continuing a tool chain must get its
515
522
  * reasoning back or it restarts from scratch every tool round trip; the
@@ -532,8 +539,37 @@ export class CopilotAdapter extends LlmAdapter {
532
539
  this.catalog = new ModelCatalogCache(options.catalogStore);
533
540
  }
534
541
  /** Discovery fetcher: resolves the session through the refresh-aware path. */
535
- async fetchCatalog() {
536
- return fetchCopilotModels(await this.options.tokens.session(), this.options.fetchFn);
542
+ async fetchCatalog(account, signal) {
543
+ return fetchCopilotModels(await this.options.tokens.session(account), this.options.fetchFn, signal);
544
+ }
545
+ /** Drop cached catalogs after login/logout so the next list does not reuse a stale plan. */
546
+ clearAccountCatalog(account) {
547
+ if (account === undefined)
548
+ this.accountCatalogs.clear();
549
+ else
550
+ this.accountCatalogs.delete(account);
551
+ if (account === undefined || this.catalogOwner === account || this.catalogOwner === undefined) {
552
+ this.catalogOwner = undefined;
553
+ this.catalog.invalidate();
554
+ }
555
+ }
556
+ /** Persisted cache for the default account; a throwaway cache for any other. */
557
+ async catalogFor(account) {
558
+ const defaultKey = await this.options.tokens.defaultAccount();
559
+ const key = account ?? defaultKey;
560
+ if (key === undefined || key === defaultKey) {
561
+ if (this.catalogOwner !== undefined && this.catalogOwner !== defaultKey) {
562
+ this.catalog.invalidate();
563
+ }
564
+ this.catalogOwner = defaultKey;
565
+ return this.catalog;
566
+ }
567
+ let cache = this.accountCatalogs.get(key);
568
+ if (cache === undefined) {
569
+ cache = new ModelCatalogCache();
570
+ this.accountCatalogs.set(key, cache);
571
+ }
572
+ return cache;
537
573
  }
538
574
  providerInfo(provider) {
539
575
  return { id: provider, name: 'GitHub Copilot' };
@@ -547,17 +583,34 @@ export class CopilotAdapter extends LlmAdapter {
547
583
  }));
548
584
  }
549
585
  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)
586
+ const own = await this.listOwnModels(provider);
587
+ const pool = this.options.pool?.();
588
+ if (pool === undefined)
589
+ return own;
590
+ const extra = await pool.modelsForProvider(provider);
591
+ const seen = new Set(own.map(model => model.id));
592
+ // Account pools reuse the catalog row; only configured tiers are extra.
593
+ return [...own, ...extra.filter(model => !seen.has(model.id))];
594
+ }
595
+ /** The provider's own catalog: union of every account, or one account when named. */
596
+ async listOwnModels(provider, account, signal) {
597
+ if (account === undefined) {
598
+ const accounts = (await this.options.tokens.list()).map(entry => entry.key);
599
+ if (accounts.length === 0)
600
+ return [];
601
+ return unionAccountCatalogs(accounts, (key, accountSignal) => this.listOwnModels(provider, key, accountSignal), { timeoutMs: DISCOVERY_TIMEOUT_MS, ...signal === undefined ? {} : { signal } });
602
+ }
603
+ if (!await this.options.tokens.hasSession(account)) {
553
604
  return [];
605
+ }
554
606
  if (!this.options.discovery)
555
607
  return this.staticModels(provider);
608
+ const catalog = await this.catalogFor(account);
556
609
  try {
557
610
  // The fetcher runs only on a cache miss, and resolves the session
558
611
  // through the refresh-aware path so an expired access token renews here
559
612
  // 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()));
613
+ const discovered = await discoverOrRetryAuth(force => this.options.tokens.session(account, force), catalog, () => catalog.get(() => this.fetchCatalog(account, signal)));
561
614
  return discovered.map(model => ({
562
615
  provider,
563
616
  id: model.id,
@@ -567,6 +620,8 @@ export class CopilotAdapter extends LlmAdapter {
567
620
  }));
568
621
  }
569
622
  catch (error) {
623
+ if (isDiscoveryAborted(error, signal))
624
+ throw error;
570
625
  // A permanent refresh failure deletes the stored session: the provider
571
626
  // is logged out, so hide it instead of showing a stale static catalog.
572
627
  if (isMissingOrInvalidCredential(error))
@@ -584,8 +639,12 @@ export class CopilotAdapter extends LlmAdapter {
584
639
  async discovered(model) {
585
640
  if (!this.options.discovery)
586
641
  return undefined;
587
- const models = await this.catalog.resolve(() => this.fetchCatalog());
588
- return models?.find(entry => entry.id === model);
642
+ const accounts = (await this.options.tokens.list()).map(entry => entry.key);
643
+ return discoverAcrossAccounts(accounts, async (account) => {
644
+ const catalog = await this.catalogFor(account);
645
+ const models = await catalog.resolve(() => this.fetchCatalog(account));
646
+ return models?.find(entry => entry.id === model);
647
+ });
589
648
  }
590
649
  /**
591
650
  * [2026-08-23]-[a manually configured responses-only model combined with
@@ -699,6 +758,14 @@ export class CopilotAdapter extends LlmAdapter {
699
758
  this.replayByScope.clear();
700
759
  }
701
760
  async resolveModel(provider, model) {
761
+ const pool = this.options.pool?.();
762
+ if (pool !== undefined && await pool.owns(provider, model)) {
763
+ return pool.resolveModel(provider, model);
764
+ }
765
+ return this.resolveOwnModel(provider, model);
766
+ }
767
+ /** Capability resolution of the provider's own models (the pool resolves members here). */
768
+ async resolveOwnModel(provider, model) {
702
769
  const discovered = await this.discovered(model);
703
770
  const configured = this.options.models.find(entry => entry.id === model);
704
771
  return {
@@ -717,6 +784,18 @@ export class CopilotAdapter extends LlmAdapter {
717
784
  };
718
785
  }
719
786
  async *stream(options) {
787
+ const pool = this.options.pool?.();
788
+ if (pool !== undefined && await pool.owns(options.provider, options.model)) {
789
+ yield* pool.stream(options);
790
+ return;
791
+ }
792
+ yield* this.streamCore(options);
793
+ }
794
+ /** Pool seam: stream through one specific account instead of the default. */
795
+ streamAccount(options, account) {
796
+ return this.streamCore(options, account);
797
+ }
798
+ async *streamCore(options, account) {
720
799
  const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
721
800
  try {
722
801
  // The discovered catalog decides the protocol: `/responses`-only model
@@ -725,7 +804,7 @@ export class CopilotAdapter extends LlmAdapter {
725
804
  // tools with a reasoning effort (gpt-5.4 400s on the chat wire then).
726
805
  // A configured `wire` outranks the catalog (see configuredWireEntry).
727
806
  const wire = copilotRequestWire(this.configuredWireEntry(options.model) ?? await this.discovered(options.model), options);
728
- let session = await this.options.tokens.session();
807
+ let session = await this.options.tokens.session(account);
729
808
  // Replay scope: account identity × conversation × model (see
730
809
  // replayScope); a Copilot-token refresh preserves the GitHub token, so
731
810
  // the 401 retry below reuses it too.
@@ -737,7 +816,7 @@ export class CopilotAdapter extends LlmAdapter {
737
816
  // means GitHub raised its minimum VS Code version, and only a fresh
738
817
  // Editor-Version header fixes that (a new token does not).
739
818
  await latestVsCodeVersion(this.options.fetchFn ?? proxiedFetch, true);
740
- session = await this.options.tokens.session(true);
819
+ session = await this.options.tokens.session(account, true);
741
820
  response = await this.request(options, session, watchdog.signal, wire, scope);
742
821
  }
743
822
  if (!response.ok)