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
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Multi-account token plumbing: one {@link AccountTokenManager} per provider
3
+ * owns a lazily-built {@link TokenManager} per account, so refresh coalescing
4
+ * (`inflight`) and permanent-failure removal stay scoped to ONE account —
5
+ * a revoked account deletes itself without touching its siblings.
6
+ *
7
+ * {@link AccountAwareAdapter} is the internal interface the pool uses to
8
+ * stream through a specific account. A catalog model listed by several
9
+ * accounts failovers; one listed by a single account is pinned to it.
10
+ */
11
+ import { LlmAdapter } from '@deepseek-ai/dsh-llm';
12
+ import type { GenerateOptions, LlmModelInfo, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm';
13
+ import { TokenManager } from './common.js';
14
+ import type { TokenManagerOptions } from './common.js';
15
+ import type { AccountEntry, ProviderId } from '../auth/store.js';
16
+ export { DISCOVERY_TIMEOUT_MS } from './common.js';
17
+ /** Minimal session shape the token managers need (mirrors common.ts). */
18
+ interface TimedSession {
19
+ accessToken: string;
20
+ refreshToken: string;
21
+ expiresAt: number;
22
+ }
23
+ /** An adapter that can stream through a named account (the pool's seam). */
24
+ export interface AccountAwareAdapter extends LlmAdapter {
25
+ /** Stream using the given account's credentials instead of the default. */
26
+ streamAccount(options: GenerateOptions, account: string): AsyncIterable<StreamChunk>;
27
+ /**
28
+ * The provider's own catalog: one account when `account` is set, otherwise
29
+ * the union of every logged-in account (default first; later duplicates
30
+ * dropped). The picker uses the union; pool assembly lists each account.
31
+ */
32
+ listOwnModels(provider: string, account?: string, signal?: AbortSignal): Promise<readonly LlmModelInfo[]>;
33
+ /**
34
+ * Capability resolution of the provider's OWN models, bypassing the pool
35
+ * delegation. The pool resolves its members through this — an account pool
36
+ * reuses the catalog wire id (e.g. `gpt-5.4`), so resolveModel would
37
+ * otherwise bounce straight back into the pool forever.
38
+ */
39
+ resolveOwnModel(provider: string, model: string): Promise<LlmResolvedModelInfo>;
40
+ /** Drop cached catalogs: one account, or every account when omitted (login/logout). */
41
+ clearAccountCatalog(account?: string): void;
42
+ }
43
+ /** Options for {@link unionAccountCatalogs}. */
44
+ export interface UnionAccountCatalogsOptions {
45
+ /** Per-account bound; a hang sits that account out instead of blocking the picker. */
46
+ timeoutMs?: number;
47
+ /** Caller cancellation; aborting drops the whole union. */
48
+ signal?: AbortSignal;
49
+ }
50
+ /**
51
+ * Merge per-account catalogs, keeping the first occurrence of each model id.
52
+ * Rows that carry a numeric `priority` (Codex discovery) are then ordered by
53
+ * it so a model only the second account lists — e.g. `gpt-5.6-sol` — still
54
+ * sits with its generation instead of being appended after the default
55
+ * account's older ids.
56
+ */
57
+ export declare function unionAccountCatalogs(accounts: readonly string[], listOne: (account: string, signal?: AbortSignal) => Promise<readonly LlmModelInfo[]>, options?: UnionAccountCatalogsOptions): Promise<LlmModelInfo[]>;
58
+ /** Store I/O behind {@link AccountTokenManager} (injectable for tests). */
59
+ export interface AccountStoreIo<S> {
60
+ list(): Promise<AccountEntry<S>[]>;
61
+ get(account?: string): Promise<S | undefined>;
62
+ save(account: string, session: S): Promise<void>;
63
+ remove(account: string): Promise<void>;
64
+ }
65
+ export interface AccountTokenManagerOptions<S extends TimedSession> {
66
+ provider: ProviderId;
67
+ /** Human-readable provider name for error messages. */
68
+ displayName: string;
69
+ /** Provider hooks shared by every account (load/save/remove are bound per account). */
70
+ makeOptions: (account: string) => Omit<TokenManagerOptions<S>, 'load' | 'save' | 'remove' | 'onRemoved' | 'displayName'>;
71
+ /** Called after a permanent refresh failure deleted one account's session. */
72
+ onAccountRemoved?: (account: string) => void;
73
+ /** Store backend; defaults to the durable auth store. */
74
+ io?: AccountStoreIo<S>;
75
+ }
76
+ export declare class AccountTokenManager<S extends TimedSession> {
77
+ private readonly options;
78
+ private readonly managers;
79
+ private readonly io;
80
+ constructor(options: AccountTokenManagerOptions<S>);
81
+ /** The provider's accounts, default first (straight from the store). */
82
+ list(): Promise<AccountEntry<S>[]>;
83
+ /** The default account's key, or undefined when logged out. */
84
+ defaultAccount(): Promise<string | undefined>;
85
+ /**
86
+ * Resolve a usable session for one account (default when omitted),
87
+ * refreshing proactively or on demand.
88
+ * @param account - the account key; the default account when undefined.
89
+ * @param forceRefresh - refresh regardless of expiry (used after a 401).
90
+ * @returns the persisted session to send.
91
+ * @throws LlmError MISSING_CREDENTIAL when the account is not logged in.
92
+ */
93
+ session(account?: string, forceRefresh?: boolean): Promise<S>;
94
+ /** Read an account's stored session without any refresh side effect. */
95
+ peek(account?: string): Promise<S | undefined>;
96
+ /** Whether a session is stored for the account (cheap; never refreshes). */
97
+ hasSession(account?: string): Promise<boolean>;
98
+ /** The TokenManager bound to one account (created lazily, then cached). */
99
+ tokensFor(account: string): TokenManager<S>;
100
+ /** The logged-out error, mirroring TokenManager's own message. */
101
+ private missingCredential;
102
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Multi-account token plumbing: one {@link AccountTokenManager} per provider
3
+ * owns a lazily-built {@link TokenManager} per account, so refresh coalescing
4
+ * (`inflight`) and permanent-failure removal stay scoped to ONE account —
5
+ * a revoked account deletes itself without touching its siblings.
6
+ *
7
+ * {@link AccountAwareAdapter} is the internal interface the pool uses to
8
+ * stream through a specific account. A catalog model listed by several
9
+ * accounts failovers; one listed by a single account is pinned to it.
10
+ */
11
+ import { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm';
12
+ import { TokenManager, withTimeout } from './common.js';
13
+ import { deleteAccountSession, getAccountSession, listAccounts, saveAccountSession, } from '../auth/store.js';
14
+ export { DISCOVERY_TIMEOUT_MS } from './common.js';
15
+ /** Catalog sort hint when the provider advertised one (Codex `priority`). */
16
+ function catalogPriority(model) {
17
+ const ranked = model;
18
+ return typeof ranked.priority === 'number' ? ranked.priority : Number.MAX_SAFE_INTEGER;
19
+ }
20
+ /**
21
+ * Merge per-account catalogs, keeping the first occurrence of each model id.
22
+ * Rows that carry a numeric `priority` (Codex discovery) are then ordered by
23
+ * it so a model only the second account lists — e.g. `gpt-5.6-sol` — still
24
+ * sits with its generation instead of being appended after the default
25
+ * account's older ids.
26
+ */
27
+ export async function unionAccountCatalogs(accounts, listOne, options) {
28
+ const timeoutMs = options?.timeoutMs;
29
+ const caller = options?.signal;
30
+ const catalogs = await Promise.all(accounts.map(async (account) => {
31
+ try {
32
+ if (timeoutMs === undefined)
33
+ return await listOne(account, caller);
34
+ const models = await withTimeout(timeoutSignal => listOne(account, caller === undefined ? timeoutSignal : AbortSignal.any([timeoutSignal, caller])), timeoutMs);
35
+ return models ?? [];
36
+ }
37
+ catch (error) {
38
+ // One expired or failing account must not hide models the others list.
39
+ if (caller?.aborted === true)
40
+ throw error;
41
+ return [];
42
+ }
43
+ }));
44
+ const seen = new Set();
45
+ const models = [];
46
+ for (const catalog of catalogs) {
47
+ for (const model of catalog) {
48
+ if (seen.has(model.id))
49
+ continue;
50
+ seen.add(model.id);
51
+ models.push(model);
52
+ }
53
+ }
54
+ models.sort((left, right) => catalogPriority(left) - catalogPriority(right));
55
+ return models;
56
+ }
57
+ export class AccountTokenManager {
58
+ options;
59
+ managers = new Map();
60
+ io;
61
+ constructor(options) {
62
+ this.options = options;
63
+ const provider = options.provider;
64
+ this.io = options.io ?? {
65
+ list: () => listAccounts(provider),
66
+ get: account => getAccountSession(provider, account),
67
+ save: (account, session) => saveAccountSession(provider, account, session),
68
+ remove: account => deleteAccountSession(provider, account),
69
+ };
70
+ }
71
+ /** The provider's accounts, default first (straight from the store). */
72
+ list() {
73
+ return this.io.list();
74
+ }
75
+ /** The default account's key, or undefined when logged out. */
76
+ async defaultAccount() {
77
+ return (await this.list())[0]?.key;
78
+ }
79
+ /**
80
+ * Resolve a usable session for one account (default when omitted),
81
+ * refreshing proactively or on demand.
82
+ * @param account - the account key; the default account when undefined.
83
+ * @param forceRefresh - refresh regardless of expiry (used after a 401).
84
+ * @returns the persisted session to send.
85
+ * @throws LlmError MISSING_CREDENTIAL when the account is not logged in.
86
+ */
87
+ async session(account, forceRefresh = false) {
88
+ const key = account ?? await this.defaultAccount();
89
+ if (key === undefined)
90
+ throw this.missingCredential();
91
+ return this.tokensFor(key).session(forceRefresh);
92
+ }
93
+ /** Read an account's stored session without any refresh side effect. */
94
+ peek(account) {
95
+ return this.io.get(account);
96
+ }
97
+ /** Whether a session is stored for the account (cheap; never refreshes). */
98
+ async hasSession(account) {
99
+ return (await this.peek(account)) !== undefined;
100
+ }
101
+ /** The TokenManager bound to one account (created lazily, then cached). */
102
+ tokensFor(account) {
103
+ let manager = this.managers.get(account);
104
+ if (manager === undefined) {
105
+ const io = this.io;
106
+ manager = new TokenManager({
107
+ displayName: this.options.displayName,
108
+ ...this.options.makeOptions(account),
109
+ load: () => io.get(account),
110
+ save: session => io.save(account, session),
111
+ remove: () => io.remove(account),
112
+ onRemoved: () => { this.options.onAccountRemoved?.(account); },
113
+ });
114
+ this.managers.set(account, manager);
115
+ }
116
+ return manager;
117
+ }
118
+ /** The logged-out error, mirroring TokenManager's own message. */
119
+ missingCredential() {
120
+ return new LlmError(`dsh-plugin-subscriptions: not logged in to ${this.options.displayName}; `
121
+ + 'log in via Settings → Subscriptions in the dsh web app', 'MISSING_CREDENTIAL');
122
+ }
123
+ }
@@ -7,9 +7,10 @@ import { LlmAdapter } from '@deepseek-ai/dsh-llm';
7
7
  import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm';
8
8
  import type { FlowSpec } from '../auth/oauth-flow.js';
9
9
  import type { ClaudeSession } from '../auth/store.js';
10
+ import type { PoolAdapter } from './pool.js';
10
11
  import type { AttachmentStore } from '@deepseek-ai/dsh-attachment';
11
12
  import type { TranslatableMessage } from '../translate/resolved.js';
12
- import { TokenManager } from './common.js';
13
+ import { AccountTokenManager } from './accounts.js';
13
14
  import type { CatalogPersistence, DiscoveredModel, FetchFn, ModelEntry, ProviderUsage } from './common.js';
14
15
  export declare const CLAUDE_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
15
16
  export declare const CLAUDE_AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
@@ -64,13 +65,15 @@ export declare const CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usa
64
65
  * @returns the mapped usage snapshot.
65
66
  */
66
67
  export declare function fetchClaudeUsage(session: ClaudeSession, fetchFn?: FetchFn, signal?: AbortSignal): Promise<ProviderUsage>;
67
- /** Fetch the live model catalog from the subscription endpoint. */
68
- export declare function fetchClaudeModels(session: ClaudeSession, fetchFn?: FetchFn): Promise<DiscoveredModel[]>;
68
+ /** Fetch the live model catalog from the subscription endpoint. `signal` cancels the request. */
69
+ export declare function fetchClaudeModels(session: ClaudeSession, fetchFn?: FetchFn, signal?: AbortSignal): Promise<DiscoveredModel[]>;
69
70
  /** Constructor dependencies for {@link ClaudeAdapter}. */
70
71
  export interface ClaudeAdapterOptions {
71
72
  models: readonly ModelEntry[];
72
73
  streamIdleTimeoutMs: number;
73
- tokens: TokenManager<ClaudeSession>;
74
+ tokens: AccountTokenManager<ClaudeSession>;
75
+ /** Late-bound pool facade (wired after adapter construction); pools list under their first member's provider. */
76
+ pool?: () => PoolAdapter | undefined;
74
77
  /** Whether to fetch the live catalog when logged in (false when config `models` overrides). */
75
78
  discovery: boolean;
76
79
  fetchFn?: FetchFn;
@@ -102,15 +105,30 @@ export declare function claudeRequestBody(options: GenerateOptions, messages: re
102
105
  export declare class ClaudeAdapter extends LlmAdapter {
103
106
  private readonly options;
104
107
  private readonly catalog;
108
+ /** In-memory catalogs for non-default accounts (the persisted cache is the default's). */
109
+ private readonly accountCatalogs;
110
+ /** Account whose snapshot currently lives in {@link catalog}; cleared on default change. */
111
+ private catalogOwner;
105
112
  constructor(options: ClaudeAdapterOptions);
106
113
  private fetchCatalog;
114
+ /** Drop cached catalogs after login/logout so the next list does not reuse a stale plan. */
115
+ clearAccountCatalog(account?: string): void;
116
+ /** Persisted cache for the default account; a throwaway cache for any other. */
117
+ private catalogFor;
107
118
  private discovered;
108
119
  private staticModels;
109
120
  providerInfo(provider: string): LlmProviderInfo;
110
121
  providerRetryPolicy(provider: string): import("@deepseek-ai/dsh-llm").ResolvedRetryPolicy | undefined;
111
122
  listModels(provider: string): Promise<readonly LlmModelInfo[]>;
123
+ /** The provider's own catalog: union of every account, or one account when named. */
124
+ listOwnModels(provider: string, account?: string, signal?: AbortSignal): Promise<readonly LlmModelInfo[]>;
112
125
  resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo>;
126
+ /** Capability resolution of the provider's own models (the pool resolves members here). */
127
+ resolveOwnModel(provider: string, model: string): Promise<LlmResolvedModelInfo>;
113
128
  stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
129
+ /** Pool seam: stream through one specific account instead of the default. */
130
+ streamAccount(options: GenerateOptions, account: string): AsyncIterable<StreamChunk>;
131
+ private streamCore;
114
132
  /**
115
133
  * `display: 'summarized'` is set explicitly on both shapes: `adaptive`-type
116
134
  * models default to `display: 'omitted'`, which returns thinking blocks with
@@ -7,7 +7,8 @@ import { execFileSync } from 'node:child_process';
7
7
  import { EMPTY_RESPONSE_CODE, errorChain, LlmAdapter, LlmError, ReasoningEffortId, resolveRetryPolicy } from '@deepseek-ai/dsh-llm';
8
8
  import { resolveImages } from '../translate/resolved.js';
9
9
  import { markMessageCache, streamAnthropic, toAnthropicMessages, toAnthropicSystem, toAnthropicTools, } from '../translate/anthropic.js';
10
- import { httpLlmError, idleWatchdog, mapFetchFailure, ModelCatalogCache, discoverOrRetryAuth, isMissingOrInvalidCredential, oauthEndpointError, OAuthEndpointError, TokenManager, } from './common.js';
10
+ import { httpLlmError, idleWatchdog, mapFetchFailure, ModelCatalogCache, discoverAcrossAccounts, discoverOrRetryAuth, isDiscoveryAborted, isMissingOrInvalidCredential, oauthEndpointError, OAuthEndpointError, } from './common.js';
11
+ import { AccountTokenManager, DISCOVERY_TIMEOUT_MS, unionAccountCatalogs } from './accounts.js';
11
12
  import { proxiedFetch } from '../http.js';
12
13
  export const CLAUDE_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
13
14
  export const CLAUDE_AUTHORIZE_URL = 'https://claude.ai/oauth/authorize';
@@ -288,8 +289,8 @@ function claudeReasoning(capabilities) {
288
289
  .map(level => ({ id: ReasoningEffortId(level), name: level[0].toUpperCase() + level.slice(1) }));
289
290
  return efforts.length > 0 ? { efforts } : undefined;
290
291
  }
291
- /** Fetch the live model catalog from the subscription endpoint. */
292
- export async function fetchClaudeModels(session, fetchFn = proxiedFetch) {
292
+ /** Fetch the live model catalog from the subscription endpoint. `signal` cancels the request. */
293
+ export async function fetchClaudeModels(session, fetchFn = proxiedFetch, signal) {
293
294
  const response = await fetchFn(CLAUDE_MODELS_URL, {
294
295
  headers: {
295
296
  'authorization': `Bearer ${session.accessToken}`,
@@ -298,6 +299,7 @@ export async function fetchClaudeModels(session, fetchFn = proxiedFetch) {
298
299
  'anthropic-dangerous-direct-browser-access': 'true',
299
300
  'accept': 'application/json',
300
301
  },
302
+ ...signal === undefined ? {} : { signal },
301
303
  });
302
304
  if (!response.ok)
303
305
  throw await httpLlmError(response, 'claude models API');
@@ -368,19 +370,56 @@ export function claudeRequestBody(options, messages, maxTokens, thinking, effort
368
370
  export class ClaudeAdapter extends LlmAdapter {
369
371
  options;
370
372
  catalog;
373
+ /** In-memory catalogs for non-default accounts (the persisted cache is the default's). */
374
+ accountCatalogs = new Map();
375
+ /** Account whose snapshot currently lives in {@link catalog}; cleared on default change. */
376
+ catalogOwner;
371
377
  constructor(options) {
372
378
  super();
373
379
  this.options = options;
374
380
  this.catalog = new ModelCatalogCache(options.catalogStore);
375
381
  }
376
- async fetchCatalog() {
377
- return fetchClaudeModels(await this.options.tokens.session(), this.options.fetchFn);
382
+ async fetchCatalog(account, signal) {
383
+ return fetchClaudeModels(await this.options.tokens.session(account), this.options.fetchFn, signal);
384
+ }
385
+ /** Drop cached catalogs after login/logout so the next list does not reuse a stale plan. */
386
+ clearAccountCatalog(account) {
387
+ if (account === undefined)
388
+ this.accountCatalogs.clear();
389
+ else
390
+ this.accountCatalogs.delete(account);
391
+ if (account === undefined || this.catalogOwner === account || this.catalogOwner === undefined) {
392
+ this.catalogOwner = undefined;
393
+ this.catalog.invalidate();
394
+ }
395
+ }
396
+ /** Persisted cache for the default account; a throwaway cache for any other. */
397
+ async catalogFor(account) {
398
+ const defaultKey = await this.options.tokens.defaultAccount();
399
+ const key = account ?? defaultKey;
400
+ if (key === undefined || key === defaultKey) {
401
+ if (this.catalogOwner !== undefined && this.catalogOwner !== defaultKey) {
402
+ this.catalog.invalidate();
403
+ }
404
+ this.catalogOwner = defaultKey;
405
+ return this.catalog;
406
+ }
407
+ let cache = this.accountCatalogs.get(key);
408
+ if (cache === undefined) {
409
+ cache = new ModelCatalogCache();
410
+ this.accountCatalogs.set(key, cache);
411
+ }
412
+ return cache;
378
413
  }
379
414
  async discovered(model) {
380
415
  if (!this.options.discovery)
381
416
  return undefined;
382
- const models = await this.catalog.resolve(() => this.fetchCatalog());
383
- return models?.find(entry => entry.id === model);
417
+ const accounts = (await this.options.tokens.list()).map(entry => entry.key);
418
+ return discoverAcrossAccounts(accounts, async (account) => {
419
+ const catalog = await this.catalogFor(account);
420
+ const models = await catalog.resolve(() => this.fetchCatalog(account));
421
+ return models?.find(entry => entry.id === model);
422
+ });
384
423
  }
385
424
  staticModels(provider) {
386
425
  return this.options.models.map(model => ({
@@ -407,12 +446,31 @@ export class ClaudeAdapter extends LlmAdapter {
407
446
  }, `claude: provider "${provider}" retryPolicy`);
408
447
  }
409
448
  async listModels(provider) {
410
- if (await this.options.tokens.peek() === undefined)
449
+ const own = await this.listOwnModels(provider);
450
+ const pool = this.options.pool?.();
451
+ if (pool === undefined)
452
+ return own;
453
+ const extra = await pool.modelsForProvider(provider);
454
+ const seen = new Set(own.map(model => model.id));
455
+ // Account pools reuse the catalog row; only configured tiers are extra.
456
+ return [...own, ...extra.filter(model => !seen.has(model.id))];
457
+ }
458
+ /** The provider's own catalog: union of every account, or one account when named. */
459
+ async listOwnModels(provider, account, signal) {
460
+ if (account === undefined) {
461
+ const accounts = (await this.options.tokens.list()).map(entry => entry.key);
462
+ if (accounts.length === 0)
463
+ return [];
464
+ return unionAccountCatalogs(accounts, (key, accountSignal) => this.listOwnModels(provider, key, accountSignal), { timeoutMs: DISCOVERY_TIMEOUT_MS, ...signal === undefined ? {} : { signal } });
465
+ }
466
+ if (!await this.options.tokens.hasSession(account)) {
411
467
  return [];
468
+ }
412
469
  if (!this.options.discovery)
413
470
  return this.staticModels(provider);
471
+ const catalog = await this.catalogFor(account);
414
472
  try {
415
- const models = await discoverOrRetryAuth(force => this.options.tokens.session(force), this.catalog, () => this.catalog.get(() => this.fetchCatalog()));
473
+ const models = await discoverOrRetryAuth(force => this.options.tokens.session(account, force), catalog, () => catalog.get(() => this.fetchCatalog(account, signal)));
416
474
  return models.map(model => ({
417
475
  provider,
418
476
  id: model.id,
@@ -421,6 +479,8 @@ export class ClaudeAdapter extends LlmAdapter {
421
479
  }));
422
480
  }
423
481
  catch (error) {
482
+ if (isDiscoveryAborted(error, signal))
483
+ throw error;
424
484
  if (isMissingOrInvalidCredential(error))
425
485
  return [];
426
486
  this.options.onWarn?.(`claude model discovery failed; using the built-in catalog (${errorChain(error)})`);
@@ -428,6 +488,14 @@ export class ClaudeAdapter extends LlmAdapter {
428
488
  }
429
489
  }
430
490
  async resolveModel(provider, model) {
491
+ const pool = this.options.pool?.();
492
+ if (pool !== undefined && await pool.owns(provider, model)) {
493
+ return pool.resolveModel(provider, model);
494
+ }
495
+ return this.resolveOwnModel(provider, model);
496
+ }
497
+ /** Capability resolution of the provider's own models (the pool resolves members here). */
498
+ async resolveOwnModel(provider, model) {
431
499
  const disc = await this.discovered(model);
432
500
  const configured = this.options.models.find(entry => entry.id === model);
433
501
  const reasoning = disc?.reasoning;
@@ -444,12 +512,24 @@ export class ClaudeAdapter extends LlmAdapter {
444
512
  };
445
513
  }
446
514
  async *stream(options) {
515
+ const pool = this.options.pool?.();
516
+ if (pool !== undefined && await pool.owns(options.provider, options.model)) {
517
+ yield* pool.stream(options);
518
+ return;
519
+ }
520
+ yield* this.streamCore(options);
521
+ }
522
+ /** Pool seam: stream through one specific account instead of the default. */
523
+ streamAccount(options, account) {
524
+ return this.streamCore(options, account);
525
+ }
526
+ async *streamCore(options, account) {
447
527
  const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
448
528
  try {
449
- let session = await this.options.tokens.session();
529
+ let session = await this.options.tokens.session(account);
450
530
  let response = await this.request(options, session, watchdog.signal);
451
531
  if (response.status === 401) {
452
- session = await this.options.tokens.session(true);
532
+ session = await this.options.tokens.session(account, true);
453
533
  response = await this.request(options, session, watchdog.signal);
454
534
  }
455
535
  if (!response.ok)
@@ -7,9 +7,10 @@ import { LlmAdapter } from '@deepseek-ai/dsh-llm';
7
7
  import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm';
8
8
  import type { FlowSpec } from '../auth/oauth-flow.js';
9
9
  import type { CodexSession } from '../auth/store.js';
10
+ import type { PoolAdapter } from './pool.js';
10
11
  import type { AttachmentStore } from '@deepseek-ai/dsh-attachment';
11
12
  import type { ResponsesRequestInput } from '../translate/responses.js';
12
- import { TokenManager } from './common.js';
13
+ import { AccountTokenManager } from './accounts.js';
13
14
  import type { CatalogPersistence, DiscoveredModel, FetchFn, ModelEntry, ProviderUsage } from './common.js';
14
15
  export declare const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
15
16
  export declare const CODEX_AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
@@ -90,14 +91,17 @@ export declare const CODEX_CLIENT_VERSION = "0.147.0";
90
91
  * Fetch the live codex model catalog with the session's auth headers.
91
92
  * @param session - the stored session (used as-is; never refreshed here).
92
93
  * @param fetchFn - fetch implementation (injectable for tests).
94
+ * @param signal - caller cancellation (pool-assembly timeout).
93
95
  * @returns discovered models: hidden entries dropped, sorted by priority.
94
96
  */
95
- export declare function fetchCodexModels(session: CodexSession, fetchFn?: FetchFn): Promise<DiscoveredModel[]>;
97
+ export declare function fetchCodexModels(session: CodexSession, fetchFn?: FetchFn, signal?: AbortSignal): Promise<DiscoveredModel[]>;
96
98
  /** Constructor dependencies for {@link CodexAdapter}. */
97
99
  export interface CodexAdapterOptions {
98
100
  models: readonly ModelEntry[];
99
101
  streamIdleTimeoutMs: number;
100
- tokens: TokenManager<CodexSession>;
102
+ tokens: AccountTokenManager<CodexSession>;
103
+ /** Late-bound pool facade (wired after adapter construction); pools list under their first member's provider. */
104
+ pool?: () => PoolAdapter | undefined;
101
105
  /** Whether to fetch the live catalog when logged in (false when config `models` overrides). */
102
106
  discovery: boolean;
103
107
  /** Warning sink for discovery failures that fall back to the static catalog. */
@@ -108,6 +112,8 @@ export interface CodexAdapterOptions {
108
112
  resolveAttachments?: () => AttachmentStore | undefined;
109
113
  /** Durable catalog store seeding capability metadata across restarts. */
110
114
  catalogStore?: CatalogPersistence;
115
+ /** Per-account catalog bound for the picker union (defaults to {@link DISCOVERY_TIMEOUT_MS}). */
116
+ discoveryTimeoutMs?: number;
111
117
  /**
112
118
  * Per-request speed lookup (the composer Speed toggle's host half). Returns
113
119
  * whether this session's current choice sends the model on the fast tier;
@@ -126,12 +132,22 @@ export declare function codexRequestBody(options: GenerateOptions, resolved: Res
126
132
  export declare class CodexAdapter extends LlmAdapter {
127
133
  private readonly options;
128
134
  private readonly catalog;
135
+ /** In-memory catalogs for non-default accounts (the persisted cache is the default's). */
136
+ private readonly accountCatalogs;
137
+ /** Account whose snapshot currently lives in {@link catalog}; cleared on default change. */
138
+ private catalogOwner;
129
139
  constructor(options: CodexAdapterOptions);
130
140
  /** Discovery fetcher: resolves the session through the refresh-aware path. */
131
141
  private fetchCatalog;
142
+ /** Drop cached catalogs after login/logout so the next list does not reuse a stale plan. */
143
+ clearAccountCatalog(account?: string): void;
144
+ /** Persisted cache for the default account; a throwaway cache for any other. */
145
+ private catalogFor;
132
146
  providerInfo(provider: string): LlmProviderInfo;
133
147
  private staticModels;
134
148
  listModels(provider: string): Promise<readonly LlmModelInfo[]>;
149
+ /** The provider's own catalog: union of every account, or one account when named. */
150
+ listOwnModels(provider: string, account?: string, signal?: AbortSignal): Promise<readonly LlmModelInfo[]>;
135
151
  /**
136
152
  * The discovered entry for one model. Resolved through the cache's
137
153
  * stale-while-revalidate path so capability metadata stays stable across a
@@ -145,6 +161,11 @@ export declare class CodexAdapter extends LlmAdapter {
145
161
  /** Ids of every discovered model with a fast tier (the Speed toggle's visibility list). */
146
162
  fastCapableModels(): Promise<string[]>;
147
163
  resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo>;
164
+ /** Capability resolution of the provider's own models (the pool resolves members here). */
165
+ resolveOwnModel(provider: string, model: string): Promise<LlmResolvedModelInfo>;
148
166
  stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
167
+ /** Pool seam: stream through one specific account instead of the default. */
168
+ streamAccount(options: GenerateOptions, account: string): AsyncIterable<StreamChunk>;
169
+ private streamCore;
149
170
  private request;
150
171
  }