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
@@ -4,11 +4,13 @@
4
4
  * the Anthropic Messages API with the Claude Code identity headers.
5
5
  */
6
6
  import { execFileSync } from 'node:child_process';
7
- import { EMPTY_RESPONSE_CODE, errorChain, LlmAdapter, LlmError, ReasoningEffortId, resolveRetryPolicy } from '@deepseek-ai/dsh-llm';
7
+ import { EMPTY_RESPONSE_CODE, errorChain, LlmAdapter, LlmError, ReasoningEffortId } 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, mergeReasoning, ModelCatalogCache, discoverAcrossAccounts, discoverOrRetryAuth, isDiscoveryAborted, isMissingOrInvalidCredential, oauthEndpointError, OAuthEndpointError, } from './common.js';
11
+ import { AccountTokenManager, DISCOVERY_TIMEOUT_MS, unionAccountCatalogs } from './accounts.js';
11
12
  import { proxiedFetch } from '../http.js';
13
+ import { DEFAULT_RATE_LIMIT_WAIT, DEFAULT_RETRY, earliestReset, jsonBody, resetFromFields, resetInstantFromHeader, subscriptionRetryPolicy, } from './rate-limit.js';
12
14
  export const CLAUDE_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
13
15
  export const CLAUDE_AUTHORIZE_URL = 'https://claude.ai/oauth/authorize';
14
16
  export const CLAUDE_TOKEN_URL = 'https://claude.ai/v1/oauth/token';
@@ -21,6 +23,30 @@ const CLAUDE_CONTEXT_WINDOW = 200_000;
21
23
  const CLAUDE_DEFAULT_MAX_TOKENS = 32_000;
22
24
  /** Refresh when the access token has less than this much life left. */
23
25
  export const CLAUDE_PREEMPT_MS = 5 * 60_000;
26
+ /**
27
+ * Body fields Anthropic uses to name a reset instant, read when the unified
28
+ * headers are absent.
29
+ */
30
+ const CLAUDE_RESET_FIELDS = ['resets_at', 'resetsAt', 'reset_at', 'retry_after'];
31
+ /**
32
+ * Reads the reset instant of the Anthropic window that rejected a request.
33
+ *
34
+ * `anthropic-ratelimit-unified-*` is the subscription-plan family — the one
35
+ * Claude Code renders as "resets 3pm" — and is the only header that names the
36
+ * window which actually rejected this request. The per-bucket
37
+ * `anthropic-ratelimit-{requests,tokens,input-tokens,output-tokens}-reset`
38
+ * headers are deliberately not read: they are rollover snapshots attached to
39
+ * every response, so on a 429 they cannot say which bucket refused, and the
40
+ * earliest of them is typically the bucket that still had room — a wait that
41
+ * lands straight back in the closed window. They reach the operator through
42
+ * `rateLimitDiagnostics` instead.
43
+ */
44
+ export const claudeRateLimitReset = (response, body, now) => {
45
+ const unified = earliestReset(resetInstantFromHeader(response, 'anthropic-ratelimit-unified-reset', now), resetInstantFromHeader(response, 'anthropic-ratelimit-unified-fallback-reset', now));
46
+ if (unified !== undefined)
47
+ return unified;
48
+ return resetFromFields(jsonBody(body), CLAUDE_RESET_FIELDS, now);
49
+ };
24
50
  /**
25
51
  * The subscription endpoint only serves requests presenting as Claude Code,
26
52
  * so these headers impersonate the CLI; the harness attribution user-agent
@@ -288,8 +314,8 @@ function claudeReasoning(capabilities) {
288
314
  .map(level => ({ id: ReasoningEffortId(level), name: level[0].toUpperCase() + level.slice(1) }));
289
315
  return efforts.length > 0 ? { efforts } : undefined;
290
316
  }
291
- /** Fetch the live model catalog from the subscription endpoint. */
292
- export async function fetchClaudeModels(session, fetchFn = proxiedFetch) {
317
+ /** Fetch the live model catalog from the subscription endpoint. `signal` cancels the request. */
318
+ export async function fetchClaudeModels(session, fetchFn = proxiedFetch, signal) {
293
319
  const response = await fetchFn(CLAUDE_MODELS_URL, {
294
320
  headers: {
295
321
  'authorization': `Bearer ${session.accessToken}`,
@@ -298,6 +324,7 @@ export async function fetchClaudeModels(session, fetchFn = proxiedFetch) {
298
324
  'anthropic-dangerous-direct-browser-access': 'true',
299
325
  'accept': 'application/json',
300
326
  },
327
+ ...signal === undefined ? {} : { signal },
301
328
  });
302
329
  if (!response.ok)
303
330
  throw await httpLlmError(response, 'claude models API');
@@ -322,14 +349,6 @@ export async function fetchClaudeModels(session, fetchFn = proxiedFetch) {
322
349
  }
323
350
  return models;
324
351
  }
325
- /**
326
- * Claude Code's own SDK retry shape: exponential backoff starting at 1s,
327
- * doubling per attempt, capped at 60s, plus jitter. `maxRetries` is the
328
- * count of retries after the first attempt (Claude Code defaults to 10).
329
- */
330
- const CLAUDE_RETRY_INITIAL_DELAY_MS = 1_000;
331
- const CLAUDE_RETRY_MAX_DELAY_MS = 60_000;
332
- const CLAUDE_RETRY_JITTER_RATIO = 0.2;
333
352
  /** The Claude 4.5 family accepts image input. */
334
353
  const CLAUDE_MODALITIES = ['text', 'image'];
335
354
  /**
@@ -368,19 +387,56 @@ export function claudeRequestBody(options, messages, maxTokens, thinking, effort
368
387
  export class ClaudeAdapter extends LlmAdapter {
369
388
  options;
370
389
  catalog;
390
+ /** In-memory catalogs for non-default accounts (the persisted cache is the default's). */
391
+ accountCatalogs = new Map();
392
+ /** Account whose snapshot currently lives in {@link catalog}; cleared on default change. */
393
+ catalogOwner;
371
394
  constructor(options) {
372
395
  super();
373
396
  this.options = options;
374
397
  this.catalog = new ModelCatalogCache(options.catalogStore);
375
398
  }
376
- async fetchCatalog() {
377
- return fetchClaudeModels(await this.options.tokens.session(), this.options.fetchFn);
399
+ async fetchCatalog(account, signal) {
400
+ return fetchClaudeModels(await this.options.tokens.session(account), this.options.fetchFn, signal);
401
+ }
402
+ /** Drop cached catalogs after login/logout so the next list does not reuse a stale plan. */
403
+ clearAccountCatalog(account) {
404
+ if (account === undefined)
405
+ this.accountCatalogs.clear();
406
+ else
407
+ this.accountCatalogs.delete(account);
408
+ if (account === undefined || this.catalogOwner === account || this.catalogOwner === undefined) {
409
+ this.catalogOwner = undefined;
410
+ this.catalog.invalidate();
411
+ }
412
+ }
413
+ /** Persisted cache for the default account; a throwaway cache for any other. */
414
+ async catalogFor(account) {
415
+ const defaultKey = await this.options.tokens.defaultAccount();
416
+ const key = account ?? defaultKey;
417
+ if (key === undefined || key === defaultKey) {
418
+ if (this.catalogOwner !== undefined && this.catalogOwner !== defaultKey) {
419
+ this.catalog.invalidate();
420
+ }
421
+ this.catalogOwner = defaultKey;
422
+ return this.catalog;
423
+ }
424
+ let cache = this.accountCatalogs.get(key);
425
+ if (cache === undefined) {
426
+ cache = new ModelCatalogCache();
427
+ this.accountCatalogs.set(key, cache);
428
+ }
429
+ return cache;
378
430
  }
379
431
  async discovered(model) {
380
432
  if (!this.options.discovery)
381
433
  return undefined;
382
- const models = await this.catalog.resolve(() => this.fetchCatalog());
383
- return models?.find(entry => entry.id === model);
434
+ const accounts = (await this.options.tokens.list()).map(entry => entry.key);
435
+ return discoverAcrossAccounts(accounts, async (account) => {
436
+ const catalog = await this.catalogFor(account);
437
+ const models = await catalog.resolve(() => this.fetchCatalog(account));
438
+ return models?.find(entry => entry.id === model);
439
+ });
384
440
  }
385
441
  staticModels(provider) {
386
442
  return this.options.models.map(model => ({
@@ -394,25 +450,34 @@ export class ClaudeAdapter extends LlmAdapter {
394
450
  return { id: provider, name: 'Claude (Subscription)' };
395
451
  }
396
452
  providerRetryPolicy(provider) {
397
- if (this.options.maxRetries === undefined)
398
- return undefined;
399
- return resolveRetryPolicy({
400
- mode: 'normal',
401
- maxRetries: this.options.maxRetries,
402
- backoff: {
403
- initialDelayMs: CLAUDE_RETRY_INITIAL_DELAY_MS,
404
- maxDelayMs: CLAUDE_RETRY_MAX_DELAY_MS,
405
- jitterRatio: CLAUDE_RETRY_JITTER_RATIO,
406
- },
407
- }, `claude: provider "${provider}" retryPolicy`);
453
+ return subscriptionRetryPolicy(DEFAULT_RETRY, this.options.rateLimit ?? DEFAULT_RATE_LIMIT_WAIT, `claude: provider "${provider}" retryPolicy`);
408
454
  }
409
455
  async listModels(provider) {
410
- if (await this.options.tokens.peek() === undefined)
456
+ const own = await this.listOwnModels(provider);
457
+ const pool = this.options.pool?.();
458
+ if (pool === undefined)
459
+ return own;
460
+ const extra = await pool.modelsForProvider(provider);
461
+ const seen = new Set(own.map(model => model.id));
462
+ // Account pools reuse the catalog row; only configured tiers are extra.
463
+ return [...own, ...extra.filter(model => !seen.has(model.id))];
464
+ }
465
+ /** The provider's own catalog: union of every account, or one account when named. */
466
+ async listOwnModels(provider, account, signal) {
467
+ if (account === undefined) {
468
+ const accounts = (await this.options.tokens.list()).map(entry => entry.key);
469
+ if (accounts.length === 0)
470
+ return [];
471
+ return unionAccountCatalogs(accounts, (key, accountSignal) => this.listOwnModels(provider, key, accountSignal), { timeoutMs: DISCOVERY_TIMEOUT_MS, ...signal === undefined ? {} : { signal } });
472
+ }
473
+ if (!await this.options.tokens.hasSession(account)) {
411
474
  return [];
475
+ }
412
476
  if (!this.options.discovery)
413
477
  return this.staticModels(provider);
478
+ const catalog = await this.catalogFor(account);
414
479
  try {
415
- const models = await discoverOrRetryAuth(force => this.options.tokens.session(force), this.catalog, () => this.catalog.get(() => this.fetchCatalog()));
480
+ const models = await discoverOrRetryAuth(force => this.options.tokens.session(account, force), catalog, () => catalog.get(() => this.fetchCatalog(account, signal)));
416
481
  return models.map(model => ({
417
482
  provider,
418
483
  id: model.id,
@@ -421,6 +486,8 @@ export class ClaudeAdapter extends LlmAdapter {
421
486
  }));
422
487
  }
423
488
  catch (error) {
489
+ if (isDiscoveryAborted(error, signal))
490
+ throw error;
424
491
  if (isMissingOrInvalidCredential(error))
425
492
  return [];
426
493
  this.options.onWarn?.(`claude model discovery failed; using the built-in catalog (${errorChain(error)})`);
@@ -428,9 +495,17 @@ export class ClaudeAdapter extends LlmAdapter {
428
495
  }
429
496
  }
430
497
  async resolveModel(provider, model) {
498
+ const pool = this.options.pool?.();
499
+ if (pool !== undefined && await pool.owns(provider, model)) {
500
+ return pool.resolveModel(provider, model);
501
+ }
502
+ return this.resolveOwnModel(provider, model);
503
+ }
504
+ /** Capability resolution of the provider's own models (the pool resolves members here). */
505
+ async resolveOwnModel(provider, model) {
431
506
  const disc = await this.discovered(model);
432
507
  const configured = this.options.models.find(entry => entry.id === model);
433
- const reasoning = disc?.reasoning;
508
+ const reasoning = mergeReasoning(this.options.defaultEffortOf?.(model), disc?.reasoning);
434
509
  return {
435
510
  provider,
436
511
  id: model,
@@ -444,16 +519,32 @@ export class ClaudeAdapter extends LlmAdapter {
444
519
  };
445
520
  }
446
521
  async *stream(options) {
522
+ const pool = this.options.pool?.();
523
+ if (pool !== undefined && await pool.owns(options.provider, options.model)) {
524
+ yield* pool.stream(options);
525
+ return;
526
+ }
527
+ yield* this.streamCore(options);
528
+ }
529
+ /** Pool seam: stream through one specific account instead of the default. */
530
+ streamAccount(options, account) {
531
+ return this.streamCore(options, account);
532
+ }
533
+ async *streamCore(options, account) {
447
534
  const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
448
535
  try {
449
- let session = await this.options.tokens.session();
536
+ let session = await this.options.tokens.session(account);
450
537
  let response = await this.request(options, session, watchdog.signal);
451
538
  if (response.status === 401) {
452
- session = await this.options.tokens.session(true);
539
+ session = await this.options.tokens.session(account, true);
453
540
  response = await this.request(options, session, watchdog.signal);
454
541
  }
455
- if (!response.ok)
456
- throw await httpLlmError(response, 'claude API');
542
+ if (!response.ok) {
543
+ throw await httpLlmError(response, 'claude API', {
544
+ rateLimitReset: claudeRateLimitReset,
545
+ ...this.options.onWarn === undefined ? {} : { onWarn: this.options.onWarn },
546
+ });
547
+ }
457
548
  if (response.body === null) {
458
549
  throw new LlmError('claude API returned no response body', EMPTY_RESPONSE_CODE);
459
550
  }
@@ -7,16 +7,29 @@ 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';
15
+ import type { RateLimitResetReader, RateLimitWait } from './rate-limit.js';
14
16
  export declare const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
15
17
  export declare const CODEX_AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
16
18
  export declare const CODEX_TOKEN_URL = "https://auth.openai.com/oauth/token";
17
19
  export declare const CODEX_API_URL = "https://chatgpt.com/backend-api/codex/responses";
18
20
  /** Refresh when the access token has less than this much life left. */
19
21
  export declare const CODEX_PREEMPT_MS: number;
22
+ /**
23
+ * Reads the reset instant of the Codex window that rejected a request.
24
+ *
25
+ * Body only. The `x-codex-{primary,secondary}-reset-after-seconds` headers are
26
+ * rollover snapshots the backend attaches to every response, one per window,
27
+ * so they say nothing about which window refused: a burst 429 that would clear
28
+ * in seconds still carries a primary rollover hours out, and reading it would
29
+ * park the turn for those hours. They reach the operator through
30
+ * `rateLimitDiagnostics` instead.
31
+ */
32
+ export declare const codexRateLimitReset: RateLimitResetReader;
20
33
  /**
21
34
  * Fast tier (the codex CLI's "fast mode"): the Responses `service_tier` wire
22
35
  * value for priority processing, mirroring codex-rs
@@ -90,14 +103,17 @@ export declare const CODEX_CLIENT_VERSION = "0.147.0";
90
103
  * Fetch the live codex model catalog with the session's auth headers.
91
104
  * @param session - the stored session (used as-is; never refreshed here).
92
105
  * @param fetchFn - fetch implementation (injectable for tests).
106
+ * @param signal - caller cancellation (pool-assembly timeout).
93
107
  * @returns discovered models: hidden entries dropped, sorted by priority.
94
108
  */
95
- export declare function fetchCodexModels(session: CodexSession, fetchFn?: FetchFn): Promise<DiscoveredModel[]>;
109
+ export declare function fetchCodexModels(session: CodexSession, fetchFn?: FetchFn, signal?: AbortSignal): Promise<DiscoveredModel[]>;
96
110
  /** Constructor dependencies for {@link CodexAdapter}. */
97
111
  export interface CodexAdapterOptions {
98
112
  models: readonly ModelEntry[];
99
113
  streamIdleTimeoutMs: number;
100
- tokens: TokenManager<CodexSession>;
114
+ tokens: AccountTokenManager<CodexSession>;
115
+ /** Late-bound pool facade (wired after adapter construction); pools list under their first member's provider. */
116
+ pool?: () => PoolAdapter | undefined;
101
117
  /** Whether to fetch the live catalog when logged in (false when config `models` overrides). */
102
118
  discovery: boolean;
103
119
  /** Warning sink for discovery failures that fall back to the static catalog. */
@@ -108,6 +124,16 @@ export interface CodexAdapterOptions {
108
124
  resolveAttachments?: () => AttachmentStore | undefined;
109
125
  /** Durable catalog store seeding capability metadata across restarts. */
110
126
  catalogStore?: CatalogPersistence;
127
+ /** Per-account catalog bound for the picker union (defaults to {@link DISCOVERY_TIMEOUT_MS}). */
128
+ discoveryTimeoutMs?: number;
129
+ /** How long this route may hold a turn open waiting for a rate-limit window; defaults to waiting on, six-hour ceiling. */
130
+ rateLimit?: RateLimitWait;
131
+ /**
132
+ * Per-model default reasoning effort override (the Settings page's picker).
133
+ * Returns the user-configured default for one model, or undefined to follow
134
+ * the provider's own default.
135
+ */
136
+ defaultEffortOf?: (model: string) => string | undefined;
111
137
  /**
112
138
  * Per-request speed lookup (the composer Speed toggle's host half). Returns
113
139
  * whether this session's current choice sends the model on the fast tier;
@@ -126,12 +152,23 @@ export declare function codexRequestBody(options: GenerateOptions, resolved: Res
126
152
  export declare class CodexAdapter extends LlmAdapter {
127
153
  private readonly options;
128
154
  private readonly catalog;
155
+ /** In-memory catalogs for non-default accounts (the persisted cache is the default's). */
156
+ private readonly accountCatalogs;
157
+ /** Account whose snapshot currently lives in {@link catalog}; cleared on default change. */
158
+ private catalogOwner;
129
159
  constructor(options: CodexAdapterOptions);
130
160
  /** Discovery fetcher: resolves the session through the refresh-aware path. */
131
161
  private fetchCatalog;
162
+ /** Drop cached catalogs after login/logout so the next list does not reuse a stale plan. */
163
+ clearAccountCatalog(account?: string): void;
164
+ /** Persisted cache for the default account; a throwaway cache for any other. */
165
+ private catalogFor;
132
166
  providerInfo(provider: string): LlmProviderInfo;
167
+ providerRetryPolicy(provider: string): import("@deepseek-ai/dsh-llm").ResolvedRetryPolicy;
133
168
  private staticModels;
134
169
  listModels(provider: string): Promise<readonly LlmModelInfo[]>;
170
+ /** The provider's own catalog: union of every account, or one account when named. */
171
+ listOwnModels(provider: string, account?: string, signal?: AbortSignal): Promise<readonly LlmModelInfo[]>;
135
172
  /**
136
173
  * The discovered entry for one model. Resolved through the cache's
137
174
  * stale-while-revalidate path so capability metadata stays stable across a
@@ -145,6 +182,11 @@ export declare class CodexAdapter extends LlmAdapter {
145
182
  /** Ids of every discovered model with a fast tier (the Speed toggle's visibility list). */
146
183
  fastCapableModels(): Promise<string[]>;
147
184
  resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo>;
185
+ /** Capability resolution of the provider's own models (the pool resolves members here). */
186
+ resolveOwnModel(provider: string, model: string): Promise<LlmResolvedModelInfo>;
148
187
  stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
188
+ /** Pool seam: stream through one specific account instead of the default. */
189
+ streamAccount(options: GenerateOptions, account: string): AsyncIterable<StreamChunk>;
190
+ private streamCore;
149
191
  private request;
150
192
  }
@@ -8,8 +8,10 @@ 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 { effortDisplayName, httpLlmError, idleWatchdog, mapFetchFailure, mergeReasoning, 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';
14
+ import { DEFAULT_RATE_LIMIT_WAIT, DEFAULT_RETRY, jsonBody, resetFromFields, subscriptionRetryPolicy, } from './rate-limit.js';
13
15
  export const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
14
16
  export const CODEX_AUTHORIZE_URL = 'https://auth.openai.com/oauth/authorize';
15
17
  export const CODEX_TOKEN_URL = 'https://auth.openai.com/oauth/token';
@@ -20,6 +22,23 @@ const CODEX_CONTEXT_WINDOW = 400_000;
20
22
  const CODEX_DEFAULT_MAX_TOKENS = 128_000;
21
23
  /** Refresh when the access token has less than this much life left. */
22
24
  export const CODEX_PREEMPT_MS = 5 * 60_000;
25
+ /**
26
+ * Body fields the backend uses to name a reset. A window-exhaustion rejection
27
+ * carries `usage_limit_reached` with the seconds left on the window — the case
28
+ * that used to classify as a terminal quota and never be retried at all.
29
+ */
30
+ const CODEX_RESET_FIELDS = ['resets_in_seconds', 'reset_after_seconds', 'resets_at', 'reset_at'];
31
+ /**
32
+ * Reads the reset instant of the Codex window that rejected a request.
33
+ *
34
+ * Body only. The `x-codex-{primary,secondary}-reset-after-seconds` headers are
35
+ * rollover snapshots the backend attaches to every response, one per window,
36
+ * so they say nothing about which window refused: a burst 429 that would clear
37
+ * in seconds still carries a primary rollover hours out, and reading it would
38
+ * park the turn for those hours. They reach the operator through
39
+ * `rateLimitDiagnostics` instead.
40
+ */
41
+ export const codexRateLimitReset = (_response, body, now) => resetFromFields(jsonBody(body), CODEX_RESET_FIELDS, now);
23
42
  /** Default instruction when the request carries no system prompt. */
24
43
  const DEFAULT_CODEX_INSTRUCTIONS = 'You are Codex, a coding agent based on GPT-5. '
25
44
  + 'Help the user with their software engineering tasks.';
@@ -291,10 +310,6 @@ export const CODEX_MODELS_URL = 'https://chatgpt.com/backend-api/codex/models';
291
310
  * the range of current codex CLI releases.
292
311
  */
293
312
  export const CODEX_CLIENT_VERSION = '0.147.0';
294
- /** Display name for a wire reasoning-effort value. */
295
- function effortName(effort) {
296
- return effort === 'xhigh' ? 'Extra High' : effort.charAt(0).toUpperCase() + effort.slice(1);
297
- }
298
313
  /**
299
314
  * Whether a catalog entry advertises the fast tier. Mirrors codex-rs
300
315
  * `ModelPreset::supports_fast_mode`: a `service_tiers` id matching the fast
@@ -308,9 +323,10 @@ function supportsFastTier(entry) {
308
323
  * Fetch the live codex model catalog with the session's auth headers.
309
324
  * @param session - the stored session (used as-is; never refreshed here).
310
325
  * @param fetchFn - fetch implementation (injectable for tests).
326
+ * @param signal - caller cancellation (pool-assembly timeout).
311
327
  * @returns discovered models: hidden entries dropped, sorted by priority.
312
328
  */
313
- export async function fetchCodexModels(session, fetchFn = proxiedFetch) {
329
+ export async function fetchCodexModels(session, fetchFn = proxiedFetch, signal) {
314
330
  const url = `${CODEX_MODELS_URL}?client_version=${CODEX_CLIENT_VERSION}`;
315
331
  const response = await fetchFn(url, {
316
332
  headers: {
@@ -320,6 +336,7 @@ export async function fetchCodexModels(session, fetchFn = proxiedFetch) {
320
336
  'accept': 'application/json',
321
337
  ...attributionHeaders(),
322
338
  },
339
+ ...signal === undefined ? {} : { signal },
323
340
  });
324
341
  if (!response.ok)
325
342
  throw await oauthEndpointError(response, 'codex models');
@@ -338,7 +355,7 @@ export async function fetchCodexModels(session, fetchFn = proxiedFetch) {
338
355
  .filter(level => typeof level.effort === 'string' && level.effort.length > 0)
339
356
  .map(level => ({
340
357
  id: ReasoningEffortId(level.effort),
341
- name: effortName(level.effort),
358
+ name: effortDisplayName(level.effort),
342
359
  ...level.description === undefined ? {} : { description: level.description },
343
360
  }));
344
361
  const defaultEffort = typeof entry.default_reasoning_level === 'string'
@@ -451,18 +468,54 @@ export function codexRequestBody(options, resolved, fast) {
451
468
  export class CodexAdapter extends LlmAdapter {
452
469
  options;
453
470
  catalog;
471
+ /** In-memory catalogs for non-default accounts (the persisted cache is the default's). */
472
+ accountCatalogs = new Map();
473
+ /** Account whose snapshot currently lives in {@link catalog}; cleared on default change. */
474
+ catalogOwner;
454
475
  constructor(options) {
455
476
  super();
456
477
  this.options = options;
457
478
  this.catalog = new ModelCatalogCache(options.catalogStore);
458
479
  }
459
480
  /** Discovery fetcher: resolves the session through the refresh-aware path. */
460
- async fetchCatalog() {
461
- return fetchCodexModels(await this.options.tokens.session(), this.options.fetchFn);
481
+ async fetchCatalog(account, signal) {
482
+ return fetchCodexModels(await this.options.tokens.session(account), this.options.fetchFn, signal);
483
+ }
484
+ /** Drop cached catalogs after login/logout so the next list does not reuse a stale plan. */
485
+ clearAccountCatalog(account) {
486
+ if (account === undefined)
487
+ this.accountCatalogs.clear();
488
+ else
489
+ this.accountCatalogs.delete(account);
490
+ if (account === undefined || this.catalogOwner === account || this.catalogOwner === undefined) {
491
+ this.catalogOwner = undefined;
492
+ this.catalog.invalidate();
493
+ }
494
+ }
495
+ /** Persisted cache for the default account; a throwaway cache for any other. */
496
+ async catalogFor(account) {
497
+ const defaultKey = await this.options.tokens.defaultAccount();
498
+ const key = account ?? defaultKey;
499
+ if (key === undefined || key === defaultKey) {
500
+ if (this.catalogOwner !== undefined && this.catalogOwner !== defaultKey) {
501
+ this.catalog.invalidate();
502
+ }
503
+ this.catalogOwner = defaultKey;
504
+ return this.catalog;
505
+ }
506
+ let cache = this.accountCatalogs.get(key);
507
+ if (cache === undefined) {
508
+ cache = new ModelCatalogCache();
509
+ this.accountCatalogs.set(key, cache);
510
+ }
511
+ return cache;
462
512
  }
463
513
  providerInfo(provider) {
464
514
  return { id: provider, name: 'ChatGPT (Codex)' };
465
515
  }
516
+ providerRetryPolicy(provider) {
517
+ return subscriptionRetryPolicy(DEFAULT_RETRY, this.options.rateLimit ?? DEFAULT_RATE_LIMIT_WAIT, `codex: provider "${provider}" retryPolicy`);
518
+ }
466
519
  staticModels(provider) {
467
520
  return this.options.models.map(model => ({
468
521
  provider,
@@ -472,26 +525,48 @@ export class CodexAdapter extends LlmAdapter {
472
525
  }));
473
526
  }
474
527
  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)
528
+ const own = await this.listOwnModels(provider);
529
+ const pool = this.options.pool?.();
530
+ if (pool === undefined)
531
+ return own;
532
+ const extra = await pool.modelsForProvider(provider);
533
+ const seen = new Set(own.map(model => model.id));
534
+ // Account pools reuse the catalog row; only configured tiers are extra.
535
+ return [...own, ...extra.filter(model => !seen.has(model.id))];
536
+ }
537
+ /** The provider's own catalog: union of every account, or one account when named. */
538
+ async listOwnModels(provider, account, signal) {
539
+ if (account === undefined) {
540
+ const accounts = (await this.options.tokens.list()).map(entry => entry.key);
541
+ if (accounts.length === 0)
542
+ return [];
543
+ return unionAccountCatalogs(accounts, (key, accountSignal) => this.listOwnModels(provider, key, accountSignal), { timeoutMs: this.options.discoveryTimeoutMs ?? DISCOVERY_TIMEOUT_MS, ...signal === undefined ? {} : { signal } });
544
+ }
545
+ if (!await this.options.tokens.hasSession(account)) {
478
546
  return [];
547
+ }
479
548
  if (!this.options.discovery)
480
549
  return this.staticModels(provider);
550
+ const catalog = await this.catalogFor(account);
481
551
  try {
482
552
  // The fetcher runs only on a cache miss, and resolves the session
483
553
  // through the refresh-aware path so an expired access token renews here
484
554
  // 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()));
555
+ const discovered = await discoverOrRetryAuth(force => this.options.tokens.session(account, force), catalog, () => catalog.get(() => this.fetchCatalog(account, signal)));
486
556
  return discovered.map(model => ({
487
557
  provider,
488
558
  id: model.id,
489
559
  name: model.name,
490
560
  ...model.description === undefined ? {} : { description: model.description },
491
561
  inputModalities: CODEX_MODALITIES,
562
+ ...model.priority === undefined ? {} : { priority: model.priority },
492
563
  }));
493
564
  }
494
565
  catch (error) {
566
+ // A cancelled discovery must not fall back to the static catalog — the
567
+ // caller (pool assembly) treats abort as "this account sits out".
568
+ if (isDiscoveryAborted(error, signal))
569
+ throw error;
495
570
  // A permanent refresh failure deletes the stored session: the provider
496
571
  // is logged out, so hide it instead of showing a stale static catalog.
497
572
  if (isMissingOrInvalidCredential(error))
@@ -510,8 +585,12 @@ export class CodexAdapter extends LlmAdapter {
510
585
  async discovered(model) {
511
586
  if (!this.options.discovery)
512
587
  return undefined;
513
- const models = await this.catalog.resolve(() => this.fetchCatalog());
514
- return models?.find(entry => entry.id === model);
588
+ const accounts = (await this.options.tokens.list()).map(entry => entry.key);
589
+ return discoverAcrossAccounts(accounts, async (account) => {
590
+ const catalog = await this.catalogFor(account);
591
+ const models = await catalog.resolve(() => this.fetchCatalog(account));
592
+ return models?.find(entry => entry.id === model);
593
+ });
515
594
  }
516
595
  /** Whether the discovered catalog advertises a fast tier for this model. */
517
596
  async supportsFastTier(model) {
@@ -522,18 +601,49 @@ export class CodexAdapter extends LlmAdapter {
522
601
  if (!this.options.discovery)
523
602
  return [];
524
603
  // 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)
604
+ // (mirrors the listModels guard above). Union every account: a fast-capable
605
+ // model only the non-default lists (e.g. gpt-5.6-sol) must still show Speed.
606
+ const accounts = (await this.options.tokens.list()).map(entry => entry.key);
607
+ if (accounts.length === 0)
528
608
  return [];
529
- const models = await this.catalog.resolve(() => this.fetchCatalog());
530
- return (models ?? []).filter(model => model.fastTier === true).map(model => model.id);
609
+ const seen = new Set();
610
+ const ids = [];
611
+ for (const account of accounts) {
612
+ try {
613
+ const catalog = await this.catalogFor(account);
614
+ const models = await catalog.resolve(() => this.fetchCatalog(account));
615
+ for (const model of models ?? []) {
616
+ if (model.fastTier !== true || seen.has(model.id))
617
+ continue;
618
+ seen.add(model.id);
619
+ ids.push(model.id);
620
+ }
621
+ }
622
+ catch {
623
+ // sit out
624
+ }
625
+ }
626
+ return ids;
531
627
  }
532
628
  async resolveModel(provider, model) {
629
+ const pool = this.options.pool?.();
630
+ if (pool !== undefined && await pool.owns(provider, model)) {
631
+ return pool.resolveModel(provider, model);
632
+ }
633
+ return this.resolveOwnModel(provider, model);
634
+ }
635
+ /** Capability resolution of the provider's own models (the pool resolves members here). */
636
+ async resolveOwnModel(provider, model) {
533
637
  // Discovered metadata (when discovery is on) wins over the static entry;
534
- // the static entry wins over the built-in defaults.
638
+ // the static entry wins over the built-in defaults. A configured default
639
+ // effort merges over both.
535
640
  const discovered = await this.discovered(model);
536
641
  const configured = this.options.models.find(entry => entry.id === model);
642
+ // `extendable` only while falling back to the built-in list: that one is
643
+ // known to trail the backend, so a configured level it omits still has to
644
+ // be selectable. A discovered catalog is the truth about what the model
645
+ // accepts, and a stale override must not be forced onto every request.
646
+ const reasoning = mergeReasoning(this.options.defaultEffortOf?.(model), discovered?.reasoning ?? { efforts: CODEX_EFFORTS, defaultEffort: CODEX_DEFAULT_EFFORT }, { extendable: discovered?.reasoning === undefined });
537
647
  return {
538
648
  provider,
539
649
  id: model,
@@ -542,21 +652,37 @@ export class CodexAdapter extends LlmAdapter {
542
652
  inputModalities: configured?.inputModalities ?? CODEX_MODALITIES,
543
653
  context: { contextWindow: discovered?.contextWindow ?? configured?.contextWindow ?? CODEX_CONTEXT_WINDOW },
544
654
  defaultMaxTokens: configured?.maxTokens ?? CODEX_DEFAULT_MAX_TOKENS,
545
- reasoning: discovered?.reasoning ?? { efforts: CODEX_EFFORTS, defaultEffort: CODEX_DEFAULT_EFFORT },
655
+ ...(reasoning === undefined ? {} : { reasoning }),
546
656
  };
547
657
  }
548
658
  async *stream(options) {
659
+ const pool = this.options.pool?.();
660
+ if (pool !== undefined && await pool.owns(options.provider, options.model)) {
661
+ yield* pool.stream(options);
662
+ return;
663
+ }
664
+ yield* this.streamCore(options);
665
+ }
666
+ /** Pool seam: stream through one specific account instead of the default. */
667
+ streamAccount(options, account) {
668
+ return this.streamCore(options, account);
669
+ }
670
+ async *streamCore(options, account) {
549
671
  const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
550
672
  try {
551
- let session = await this.options.tokens.session();
673
+ let session = await this.options.tokens.session(account);
552
674
  let response = await this.request(options, session, watchdog.signal);
553
675
  if (response.status === 401) {
554
676
  // One forced refresh + retry on an unexpired-but-rejected token.
555
- session = await this.options.tokens.session(true);
677
+ session = await this.options.tokens.session(account, true);
556
678
  response = await this.request(options, session, watchdog.signal);
557
679
  }
558
- if (!response.ok)
559
- throw await httpLlmError(response, 'codex API');
680
+ if (!response.ok) {
681
+ throw await httpLlmError(response, 'codex API', {
682
+ rateLimitReset: codexRateLimitReset,
683
+ ...this.options.onWarn === undefined ? {} : { onWarn: this.options.onWarn },
684
+ });
685
+ }
560
686
  if (response.body === null) {
561
687
  throw new LlmError('codex API returned no response body', EMPTY_RESPONSE_CODE);
562
688
  }