dsh-plugin-subscriptions 0.5.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +45 -6
  2. package/README.zh.md +43 -4
  3. package/lib/auth/rpc.d.ts +36 -2
  4. package/lib/auth/rpc.js +47 -5
  5. package/lib/client/ImageGenerateToolview.d.ts +1 -1
  6. package/lib/client/SpeedSelect.d.ts +25 -2
  7. package/lib/client/SpeedSelect.js +10 -6
  8. package/lib/client/SubscriptionsSection.d.ts +74 -0
  9. package/lib/client/SubscriptionsSection.js +325 -4
  10. package/lib/client/VideoGenerateToolview.d.ts +1 -1
  11. package/lib/client/index.d.ts +1 -9
  12. package/lib/client/index.js +7 -4
  13. package/lib/client/locales.d.ts +28 -0
  14. package/lib/client/locales.js +28 -0
  15. package/lib/client.js +458 -10
  16. package/lib/client.js.map +1 -1
  17. package/lib/compat.d.ts +36 -0
  18. package/lib/compat.js +20 -0
  19. package/lib/index.d.ts +5 -1
  20. package/lib/index.js +865 -111
  21. package/lib/model-defaults.d.ts +23 -0
  22. package/lib/model-defaults.js +237 -0
  23. package/lib/providers/claude.d.ts +24 -3
  24. package/lib/providers/claude.js +35 -24
  25. package/lib/providers/codex.d.ts +21 -0
  26. package/lib/providers/codex.js +37 -10
  27. package/lib/providers/common.d.ts +70 -6
  28. package/lib/providers/common.js +118 -19
  29. package/lib/providers/copilot.d.ts +10 -0
  30. package/lib/providers/copilot.js +21 -8
  31. package/lib/providers/grok.d.ts +21 -0
  32. package/lib/providers/grok.js +37 -7
  33. package/lib/providers/pool-usage.d.ts +23 -2
  34. package/lib/providers/pool-usage.js +70 -15
  35. package/lib/providers/rate-limit.d.ts +192 -0
  36. package/lib/providers/rate-limit.js +338 -0
  37. package/lib/translate/anthropic.js +5 -4
  38. package/lib/translate/chat-completions.js +5 -4
  39. package/lib/translate/responses.js +5 -4
  40. package/package.json +21 -21
  41. package/lib/providers/antigravity.d.ts +0 -90
  42. package/lib/providers/antigravity.js +0 -392
  43. package/lib/translate/antigravity.d.ts +0 -110
  44. package/lib/translate/antigravity.js +0 -303
@@ -0,0 +1,23 @@
1
+ import { type ProviderId } from './auth/store.js';
2
+ /** One model id → its configured default reasoning effort id. */
3
+ export type ModelDefaultMap = Readonly<Record<string, string>>;
4
+ /** Provider route → model defaults. */
5
+ export type ModelDefaults = Readonly<Partial<Record<ProviderId, ModelDefaultMap>>>;
6
+ /** Absolute path of the defaults file. */
7
+ export declare function modelDefaultsFilePath(): string;
8
+ /**
9
+ * The last load failure, or a warning about entries that were skipped while
10
+ * loading; consumers only use it for diagnostics.
11
+ */
12
+ export declare function modelDefaultsLoadError(): unknown;
13
+ /** A detached snapshot for the RPC surface (render + diffing). */
14
+ export declare function modelDefaultsSnapshot(): ModelDefaults;
15
+ /**
16
+ * Set or clear one model's configured default effort, then persist. The
17
+ * memory snapshot updates only after the atomic write succeeds, so a failed
18
+ * write never leaves the live state ahead of the file.
19
+ * @param provider - the subscription provider route.
20
+ * @param model - the wire model id.
21
+ * @param effort - the effort id, or undefined to clear the override.
22
+ */
23
+ export declare function setDefaultEffort(provider: ProviderId, model: string, effort: string | undefined): Promise<void>;
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Per-model default reasoning effort overrides — the durable half of the
3
+ * Settings page's per-model "default effort" pickers.
4
+ *
5
+ * The file lives at `~/.dsh/plugins/subscriptions/model-defaults.json`
6
+ * (mode 0600, atomic replace). Shape: `{ "<provider>": { "<model id>": "<effort>" } }`.
7
+ * An absent entry means "follow the provider's own default": the `Default`
8
+ * chip the model picker shows when the discovered catalog advertises no
9
+ * default at all.
10
+ *
11
+ * Writes are single-process and atomic, but *not* as serialised as the rest
12
+ * of the page: the Settings page disables only the row being saved, so two
13
+ * rows saved back to back can overlap. The write chain below serialises them,
14
+ * so no update is lost to a read-modify-write race. Every read comes from the
15
+ * in-memory snapshot, so the on-disk file only needs to survive a restart: a
16
+ * malformed file reads as empty and is rewritten on the next save, never
17
+ * taking the plugin down with it.
18
+ */
19
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
20
+ import { dirname } from 'node:path';
21
+ import { dshHomePath } from '@deepseek-ai/dsh-home-paths';
22
+ import { PROVIDER_IDS } from './auth/store.js';
23
+ /** Absolute path of the defaults file. */
24
+ export function modelDefaultsFilePath() {
25
+ return dshHomePath('plugins', 'subscriptions', 'model-defaults.json');
26
+ }
27
+ const EMPTY = Object.freeze({});
28
+ /** In-memory snapshot read by every consumer (adapters, RPC). */
29
+ let current = EMPTY;
30
+ /** One lazy load of the on-disk file (read once per process). */
31
+ let ready;
32
+ /** Last load failure, surfaced to callers that care; defaults stay empty. */
33
+ let loadError;
34
+ /**
35
+ * Serialises every write: the read-modify-write sequence must not interleave,
36
+ * or a fast second save would compute its snapshot from the stale `current`
37
+ * and silently drop the first update (the UI disables only the row being
38
+ * saved, so overlaps are reachable).
39
+ */
40
+ let writeChain = Promise.resolve();
41
+ /**
42
+ * Validate one persisted provider section: a string→string map, or undefined.
43
+ * Malformed *entries* are skipped, not the whole section: one bad value (a
44
+ * hand edit losing its quotes) must not silently un-configure every model in
45
+ * that provider. What was dropped is reported so the caller can surface it
46
+ * instead of the loss disappearing.
47
+ */
48
+ function sanitizeProvider(value, dropped) {
49
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
50
+ return undefined;
51
+ const entries = {};
52
+ for (const [model, effort] of Object.entries(value)) {
53
+ if (typeof effort !== 'string' || effort.length === 0) {
54
+ dropped.push(model);
55
+ continue;
56
+ }
57
+ entries[model] = effort;
58
+ }
59
+ if (Object.keys(entries).length === 0)
60
+ return undefined;
61
+ return Object.freeze(entries);
62
+ }
63
+ /** Validate the raw document: only known providers, malformed sections dropped. */
64
+ function sanitizeDefaults(value) {
65
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
66
+ return { defaults: EMPTY, dropped: [] };
67
+ const record = value;
68
+ const result = {};
69
+ const dropped = [];
70
+ for (const provider of PROVIDER_IDS) {
71
+ const section = sanitizeProvider(record[provider], dropped);
72
+ if (section !== undefined)
73
+ result[provider] = section;
74
+ }
75
+ return { defaults: Object.freeze(result), dropped };
76
+ }
77
+ /** Read and validate the on-disk file; a missing file reads as empty. */
78
+ async function loadFile(path) {
79
+ let text;
80
+ try {
81
+ text = await readFile(path, 'utf8');
82
+ }
83
+ catch (error) {
84
+ if (error.code === 'ENOENT')
85
+ return EMPTY;
86
+ throw error;
87
+ }
88
+ try {
89
+ const { defaults, dropped } = sanitizeDefaults(JSON.parse(text));
90
+ if (dropped.length > 0)
91
+ loadError = new Error(`subscriptions model defaults: ${dropped.length} malformed entr${dropped.length === 1 ? 'y' : 'ies'} skipped (${dropped.join(', ')}); fix or delete the file`);
92
+ return defaults;
93
+ }
94
+ catch {
95
+ throw new Error(`subscriptions model defaults at ${path} are not valid JSON; fix or delete the file`);
96
+ }
97
+ }
98
+ /** Resolve the module state once from disk; failures leave the defaults empty. */
99
+ async function ensureReady() {
100
+ ready ??= loadFile(modelDefaultsFilePath()).then((loaded) => {
101
+ current = loaded;
102
+ // loadFile itself sets loadError for skipped entries; do not clobber it.
103
+ }, (error) => {
104
+ loadError = error;
105
+ current = EMPTY;
106
+ });
107
+ return ready;
108
+ }
109
+ /** Persist a snapshot atomically with owner-only permissions. */
110
+ async function atomicPersist(defaults, path) {
111
+ await mkdir(dirname(path), { recursive: true });
112
+ const tmp = `${path}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
113
+ try {
114
+ await writeFile(tmp, JSON.stringify(defaults, null, 2), { mode: 0o600 });
115
+ await chmod(tmp, 0o600);
116
+ await rename(tmp, path);
117
+ }
118
+ catch (error) {
119
+ await rm(tmp, { force: true });
120
+ throw error;
121
+ }
122
+ }
123
+ let persistDefaults = atomicPersist;
124
+ /**
125
+ * Clone one provider section, or undefined when nothing is configured for it.
126
+ * The clone is prototype-less: model ids are provider-supplied catalog data
127
+ * used as object keys, and consumers index the section directly (the RPC
128
+ * catalog in index.ts does), so an id like `toString` would otherwise yield an
129
+ * inherited *function* where a string is declared.
130
+ */
131
+ function sectionOf(defaults, provider) {
132
+ const section = defaults[provider];
133
+ if (section === undefined)
134
+ return undefined;
135
+ return Object.assign(Object.create(null), section);
136
+ }
137
+ /**
138
+ * Ready the defaults store.
139
+ * @internal Exported for tests; index.ts calls it at apply time so every
140
+ * later synchronous read sees the persisted state.
141
+ */
142
+ export async function loadModelDefaults() {
143
+ await ensureReady();
144
+ }
145
+ /**
146
+ * The last load failure, or a warning about entries that were skipped while
147
+ * loading; consumers only use it for diagnostics.
148
+ */
149
+ export function modelDefaultsLoadError() {
150
+ return loadError;
151
+ }
152
+ /**
153
+ * The configured default effort for one model, or undefined when none (the
154
+ * picker then follows the provider's own default).
155
+ * @internal Exported for the adapters' `defaultEffortOf` options.
156
+ */
157
+ export function defaultEffortOf(provider, model) {
158
+ const section = current[provider];
159
+ if (section === undefined)
160
+ return undefined;
161
+ // Own-property lookup: a model id is provider-supplied catalog data, and a
162
+ // plain index would inherit from Object.prototype for names like
163
+ // `toString`, handing a *function* to mergeReasoning (which then throws and
164
+ // breaks that model's resolution).
165
+ return Object.prototype.hasOwnProperty.call(section, model) ? section[model] : undefined;
166
+ }
167
+ /** A detached snapshot for the RPC surface (render + diffing). */
168
+ export function modelDefaultsSnapshot() {
169
+ const result = {};
170
+ for (const provider of PROVIDER_IDS) {
171
+ const section = sectionOf(current, provider);
172
+ if (section !== undefined)
173
+ result[provider] = section;
174
+ }
175
+ return Object.freeze(result);
176
+ }
177
+ /**
178
+ * Set or clear one model's configured default effort, then persist. The
179
+ * memory snapshot updates only after the atomic write succeeds, so a failed
180
+ * write never leaves the live state ahead of the file.
181
+ * @param provider - the subscription provider route.
182
+ * @param model - the wire model id.
183
+ * @param effort - the effort id, or undefined to clear the override.
184
+ */
185
+ export function setDefaultEffort(provider, model, effort) {
186
+ // Chained behind every earlier write: the snapshot `current` is read inside
187
+ // the chain, so two overlapping saves cannot lose either update. The caller
188
+ // receives the promise of its own write (a rejection propagates), not the
189
+ // shared chain.
190
+ const run = writeChain.then(async () => {
191
+ await ensureReady();
192
+ const section = { ...sectionOf(current, provider) ?? {} };
193
+ if (effort === undefined) {
194
+ delete section[model];
195
+ }
196
+ else {
197
+ section[model] = effort;
198
+ }
199
+ const next = { ...current };
200
+ if (Object.keys(section).length === 0) {
201
+ delete next[provider];
202
+ }
203
+ else {
204
+ next[provider] = Object.freeze(section);
205
+ }
206
+ const frozen = Object.freeze(next);
207
+ await persistDefaults(frozen, modelDefaultsFilePath());
208
+ current = frozen;
209
+ });
210
+ // Keep the chain alive even when one write fails, or every later save would
211
+ // be stuck behind the rejected promise. The caller has already received the
212
+ // rejection through `run`.
213
+ writeChain = run.catch(() => undefined);
214
+ return run;
215
+ }
216
+ /**
217
+ * Drop the in-memory state and the cached load. Test-only: lets a suite
218
+ * unwind the lazy singleton before the next `loadModelDefaults`.
219
+ * @internal Exported for tests only; not part of the plugin's public surface.
220
+ */
221
+ export async function resetModelDefaultsForTests() {
222
+ current = EMPTY;
223
+ ready = undefined;
224
+ loadError = undefined;
225
+ writeChain = Promise.resolve();
226
+ persistDefaults = atomicPersist;
227
+ }
228
+ /**
229
+ * Test-only seam: replace the atomic persistence so a failure happens on the
230
+ * real write path. Proves a failed write propagates to the caller and does
231
+ * not wedge the write chain (resetModelDefaultsForTests restores the real
232
+ * implementation).
233
+ * @internal Exported for tests only.
234
+ */
235
+ export function overridePersistForTests(persist) {
236
+ persistDefaults = persist;
237
+ }
@@ -12,6 +12,7 @@ import type { AttachmentStore } from '@deepseek-ai/dsh-attachment';
12
12
  import type { TranslatableMessage } from '../translate/resolved.js';
13
13
  import { AccountTokenManager } from './accounts.js';
14
14
  import type { CatalogPersistence, DiscoveredModel, FetchFn, ModelEntry, ProviderUsage } from './common.js';
15
+ import type { RateLimitResetReader, RateLimitWait } from './rate-limit.js';
15
16
  export declare const CLAUDE_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
16
17
  export declare const CLAUDE_AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
17
18
  export declare const CLAUDE_TOKEN_URL = "https://claude.ai/v1/oauth/token";
@@ -22,6 +23,20 @@ export declare const CLAUDE_SCOPE = "org:create_api_key user:profile user:infere
22
23
  export declare const CLAUDE_CALLBACK_PATH = "/callback";
23
24
  /** Refresh when the access token has less than this much life left. */
24
25
  export declare const CLAUDE_PREEMPT_MS: number;
26
+ /**
27
+ * Reads the reset instant of the Anthropic window that rejected a request.
28
+ *
29
+ * `anthropic-ratelimit-unified-*` is the subscription-plan family — the one
30
+ * Claude Code renders as "resets 3pm" — and is the only header that names the
31
+ * window which actually rejected this request. The per-bucket
32
+ * `anthropic-ratelimit-{requests,tokens,input-tokens,output-tokens}-reset`
33
+ * headers are deliberately not read: they are rollover snapshots attached to
34
+ * every response, so on a 429 they cannot say which bucket refused, and the
35
+ * earliest of them is typically the bucket that still had room — a wait that
36
+ * lands straight back in the closed window. They reach the operator through
37
+ * `rateLimitDiagnostics` instead.
38
+ */
39
+ export declare const claudeRateLimitReset: RateLimitResetReader;
25
40
  /**
26
41
  * The subscription endpoint only serves requests presenting as Claude Code,
27
42
  * so these headers impersonate the CLI; the harness attribution user-agent
@@ -78,12 +93,18 @@ export interface ClaudeAdapterOptions {
78
93
  discovery: boolean;
79
94
  fetchFn?: FetchFn;
80
95
  onWarn?: (message: string) => void;
81
- /** Max retries on a retryable failure before giving up; matches Claude Code's own client-side retry count. Defaults to the dsh-llm default (2) when unset. */
82
- maxRetries?: number;
96
+ /** How long this route may hold a turn open waiting for a rate-limit window; defaults to waiting on, six-hour ceiling. */
97
+ rateLimit?: RateLimitWait;
83
98
  /** Resolve the attachment service per request; absent means image requests fail loudly. */
84
99
  resolveAttachments?: () => AttachmentStore | undefined;
85
100
  /** Durable catalog store seeding capability metadata across restarts. */
86
101
  catalogStore?: CatalogPersistence;
102
+ /**
103
+ * Per-model default reasoning effort override (the Settings page's picker).
104
+ * Returns the user-configured default for one model, or undefined to follow
105
+ * the provider's own default.
106
+ */
107
+ defaultEffortOf?: (model: string) => string | undefined;
87
108
  }
88
109
  /**
89
110
  * Assemble the Anthropic request body.
@@ -118,7 +139,7 @@ export declare class ClaudeAdapter extends LlmAdapter {
118
139
  private discovered;
119
140
  private staticModels;
120
141
  providerInfo(provider: string): LlmProviderInfo;
121
- providerRetryPolicy(provider: string): import("@deepseek-ai/dsh-llm").ResolvedRetryPolicy | undefined;
142
+ providerRetryPolicy(provider: string): import("@deepseek-ai/dsh-llm").ResolvedRetryPolicy;
122
143
  listModels(provider: string): Promise<readonly LlmModelInfo[]>;
123
144
  /** The provider's own catalog: union of every account, or one account when named. */
124
145
  listOwnModels(provider: string, account?: string, signal?: AbortSignal): Promise<readonly LlmModelInfo[]>;
@@ -4,12 +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, discoverAcrossAccounts, discoverOrRetryAuth, isDiscoveryAborted, isMissingOrInvalidCredential, oauthEndpointError, OAuthEndpointError, } from './common.js';
10
+ import { httpLlmError, idleWatchdog, mapFetchFailure, mergeReasoning, ModelCatalogCache, discoverAcrossAccounts, discoverOrRetryAuth, isDiscoveryAborted, isMissingOrInvalidCredential, oauthEndpointError, OAuthEndpointError, } from './common.js';
11
11
  import { AccountTokenManager, DISCOVERY_TIMEOUT_MS, unionAccountCatalogs } from './accounts.js';
12
12
  import { proxiedFetch } from '../http.js';
13
+ import { DEFAULT_RATE_LIMIT_WAIT, DEFAULT_RETRY, earliestReset, jsonBody, resetFromFields, resetInstantFromHeader, subscriptionRetryPolicy, } from './rate-limit.js';
13
14
  export const CLAUDE_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
14
15
  export const CLAUDE_AUTHORIZE_URL = 'https://claude.ai/oauth/authorize';
15
16
  export const CLAUDE_TOKEN_URL = 'https://claude.ai/v1/oauth/token';
@@ -22,6 +23,30 @@ const CLAUDE_CONTEXT_WINDOW = 200_000;
22
23
  const CLAUDE_DEFAULT_MAX_TOKENS = 32_000;
23
24
  /** Refresh when the access token has less than this much life left. */
24
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
+ };
25
50
  /**
26
51
  * The subscription endpoint only serves requests presenting as Claude Code,
27
52
  * so these headers impersonate the CLI; the harness attribution user-agent
@@ -324,14 +349,6 @@ export async function fetchClaudeModels(session, fetchFn = proxiedFetch, signal)
324
349
  }
325
350
  return models;
326
351
  }
327
- /**
328
- * Claude Code's own SDK retry shape: exponential backoff starting at 1s,
329
- * doubling per attempt, capped at 60s, plus jitter. `maxRetries` is the
330
- * count of retries after the first attempt (Claude Code defaults to 10).
331
- */
332
- const CLAUDE_RETRY_INITIAL_DELAY_MS = 1_000;
333
- const CLAUDE_RETRY_MAX_DELAY_MS = 60_000;
334
- const CLAUDE_RETRY_JITTER_RATIO = 0.2;
335
352
  /** The Claude 4.5 family accepts image input. */
336
353
  const CLAUDE_MODALITIES = ['text', 'image'];
337
354
  /**
@@ -433,17 +450,7 @@ export class ClaudeAdapter extends LlmAdapter {
433
450
  return { id: provider, name: 'Claude (Subscription)' };
434
451
  }
435
452
  providerRetryPolicy(provider) {
436
- if (this.options.maxRetries === undefined)
437
- return undefined;
438
- return resolveRetryPolicy({
439
- mode: 'normal',
440
- maxRetries: this.options.maxRetries,
441
- backoff: {
442
- initialDelayMs: CLAUDE_RETRY_INITIAL_DELAY_MS,
443
- maxDelayMs: CLAUDE_RETRY_MAX_DELAY_MS,
444
- jitterRatio: CLAUDE_RETRY_JITTER_RATIO,
445
- },
446
- }, `claude: provider "${provider}" retryPolicy`);
453
+ return subscriptionRetryPolicy(DEFAULT_RETRY, this.options.rateLimit ?? DEFAULT_RATE_LIMIT_WAIT, `claude: provider "${provider}" retryPolicy`);
447
454
  }
448
455
  async listModels(provider) {
449
456
  const own = await this.listOwnModels(provider);
@@ -498,7 +505,7 @@ export class ClaudeAdapter extends LlmAdapter {
498
505
  async resolveOwnModel(provider, model) {
499
506
  const disc = await this.discovered(model);
500
507
  const configured = this.options.models.find(entry => entry.id === model);
501
- const reasoning = disc?.reasoning;
508
+ const reasoning = mergeReasoning(this.options.defaultEffortOf?.(model), disc?.reasoning);
502
509
  return {
503
510
  provider,
504
511
  id: model,
@@ -532,8 +539,12 @@ export class ClaudeAdapter extends LlmAdapter {
532
539
  session = await this.options.tokens.session(account, true);
533
540
  response = await this.request(options, session, watchdog.signal);
534
541
  }
535
- if (!response.ok)
536
- 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
+ }
537
548
  if (response.body === null) {
538
549
  throw new LlmError('claude API returned no response body', EMPTY_RESPONSE_CODE);
539
550
  }
@@ -12,12 +12,24 @@ import type { AttachmentStore } from '@deepseek-ai/dsh-attachment';
12
12
  import type { ResponsesRequestInput } from '../translate/responses.js';
13
13
  import { AccountTokenManager } from './accounts.js';
14
14
  import type { CatalogPersistence, DiscoveredModel, FetchFn, ModelEntry, ProviderUsage } from './common.js';
15
+ import type { RateLimitResetReader, RateLimitWait } from './rate-limit.js';
15
16
  export declare const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
16
17
  export declare const CODEX_AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
17
18
  export declare const CODEX_TOKEN_URL = "https://auth.openai.com/oauth/token";
18
19
  export declare const CODEX_API_URL = "https://chatgpt.com/backend-api/codex/responses";
19
20
  /** Refresh when the access token has less than this much life left. */
20
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;
21
33
  /**
22
34
  * Fast tier (the codex CLI's "fast mode"): the Responses `service_tier` wire
23
35
  * value for priority processing, mirroring codex-rs
@@ -114,6 +126,14 @@ export interface CodexAdapterOptions {
114
126
  catalogStore?: CatalogPersistence;
115
127
  /** Per-account catalog bound for the picker union (defaults to {@link DISCOVERY_TIMEOUT_MS}). */
116
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;
117
137
  /**
118
138
  * Per-request speed lookup (the composer Speed toggle's host half). Returns
119
139
  * whether this session's current choice sends the model on the fast tier;
@@ -144,6 +164,7 @@ export declare class CodexAdapter extends LlmAdapter {
144
164
  /** Persisted cache for the default account; a throwaway cache for any other. */
145
165
  private catalogFor;
146
166
  providerInfo(provider: string): LlmProviderInfo;
167
+ providerRetryPolicy(provider: string): import("@deepseek-ai/dsh-llm").ResolvedRetryPolicy;
147
168
  private staticModels;
148
169
  listModels(provider: string): Promise<readonly LlmModelInfo[]>;
149
170
  /** The provider's own catalog: union of every account, or one account when named. */
@@ -8,9 +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, discoverAcrossAccounts, discoverOrRetryAuth, isDiscoveryAborted, isMissingOrInvalidCredential, oauthEndpointError, OAuthEndpointError, } from './common.js';
11
+ import { effortDisplayName, httpLlmError, idleWatchdog, mapFetchFailure, mergeReasoning, ModelCatalogCache, discoverAcrossAccounts, discoverOrRetryAuth, isDiscoveryAborted, isMissingOrInvalidCredential, oauthEndpointError, OAuthEndpointError, } from './common.js';
12
12
  import { AccountTokenManager, DISCOVERY_TIMEOUT_MS, unionAccountCatalogs } from './accounts.js';
13
13
  import { proxiedFetch } from '../http.js';
14
+ import { DEFAULT_RATE_LIMIT_WAIT, DEFAULT_RETRY, jsonBody, resetFromFields, subscriptionRetryPolicy, } from './rate-limit.js';
14
15
  export const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
15
16
  export const CODEX_AUTHORIZE_URL = 'https://auth.openai.com/oauth/authorize';
16
17
  export const CODEX_TOKEN_URL = 'https://auth.openai.com/oauth/token';
@@ -21,6 +22,23 @@ const CODEX_CONTEXT_WINDOW = 400_000;
21
22
  const CODEX_DEFAULT_MAX_TOKENS = 128_000;
22
23
  /** Refresh when the access token has less than this much life left. */
23
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);
24
42
  /** Default instruction when the request carries no system prompt. */
25
43
  const DEFAULT_CODEX_INSTRUCTIONS = 'You are Codex, a coding agent based on GPT-5. '
26
44
  + 'Help the user with their software engineering tasks.';
@@ -292,10 +310,6 @@ export const CODEX_MODELS_URL = 'https://chatgpt.com/backend-api/codex/models';
292
310
  * the range of current codex CLI releases.
293
311
  */
294
312
  export const CODEX_CLIENT_VERSION = '0.147.0';
295
- /** Display name for a wire reasoning-effort value. */
296
- function effortName(effort) {
297
- return effort === 'xhigh' ? 'Extra High' : effort.charAt(0).toUpperCase() + effort.slice(1);
298
- }
299
313
  /**
300
314
  * Whether a catalog entry advertises the fast tier. Mirrors codex-rs
301
315
  * `ModelPreset::supports_fast_mode`: a `service_tiers` id matching the fast
@@ -341,7 +355,7 @@ export async function fetchCodexModels(session, fetchFn = proxiedFetch, signal)
341
355
  .filter(level => typeof level.effort === 'string' && level.effort.length > 0)
342
356
  .map(level => ({
343
357
  id: ReasoningEffortId(level.effort),
344
- name: effortName(level.effort),
358
+ name: effortDisplayName(level.effort),
345
359
  ...level.description === undefined ? {} : { description: level.description },
346
360
  }));
347
361
  const defaultEffort = typeof entry.default_reasoning_level === 'string'
@@ -499,6 +513,9 @@ export class CodexAdapter extends LlmAdapter {
499
513
  providerInfo(provider) {
500
514
  return { id: provider, name: 'ChatGPT (Codex)' };
501
515
  }
516
+ providerRetryPolicy(provider) {
517
+ return subscriptionRetryPolicy(DEFAULT_RETRY, this.options.rateLimit ?? DEFAULT_RATE_LIMIT_WAIT, `codex: provider "${provider}" retryPolicy`);
518
+ }
502
519
  staticModels(provider) {
503
520
  return this.options.models.map(model => ({
504
521
  provider,
@@ -618,9 +635,15 @@ export class CodexAdapter extends LlmAdapter {
618
635
  /** Capability resolution of the provider's own models (the pool resolves members here). */
619
636
  async resolveOwnModel(provider, model) {
620
637
  // Discovered metadata (when discovery is on) wins over the static entry;
621
- // 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.
622
640
  const discovered = await this.discovered(model);
623
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 });
624
647
  return {
625
648
  provider,
626
649
  id: model,
@@ -629,7 +652,7 @@ export class CodexAdapter extends LlmAdapter {
629
652
  inputModalities: configured?.inputModalities ?? CODEX_MODALITIES,
630
653
  context: { contextWindow: discovered?.contextWindow ?? configured?.contextWindow ?? CODEX_CONTEXT_WINDOW },
631
654
  defaultMaxTokens: configured?.maxTokens ?? CODEX_DEFAULT_MAX_TOKENS,
632
- reasoning: discovered?.reasoning ?? { efforts: CODEX_EFFORTS, defaultEffort: CODEX_DEFAULT_EFFORT },
655
+ ...(reasoning === undefined ? {} : { reasoning }),
633
656
  };
634
657
  }
635
658
  async *stream(options) {
@@ -654,8 +677,12 @@ export class CodexAdapter extends LlmAdapter {
654
677
  session = await this.options.tokens.session(account, true);
655
678
  response = await this.request(options, session, watchdog.signal);
656
679
  }
657
- if (!response.ok)
658
- 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
+ }
659
686
  if (response.body === null) {
660
687
  throw new LlmError('codex API returned no response body', EMPTY_RESPONSE_CODE);
661
688
  }