dsh-subscriptions 0.1.2 → 0.1.4

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 (68) hide show
  1. package/README.md +92 -10
  2. package/lib/auth/rpc.d.ts +3 -1
  3. package/lib/auth/rpc.js +10 -1
  4. package/lib/client/ExternalUsageCards.d.ts +11 -1
  5. package/lib/client/ExternalUsageCards.js +18 -3
  6. package/lib/client/ProviderAccountManager.js +34 -41
  7. package/lib/client/ProviderModelEditor.js +5 -9
  8. package/lib/client/SpeedSelect.js +8 -7
  9. package/lib/client/SubscriptionUsageBadge.d.ts +19 -2
  10. package/lib/client/SubscriptionUsageBadge.js +65 -9
  11. package/lib/client/SubscriptionsSection.d.ts +22 -0
  12. package/lib/client/SubscriptionsSection.js +128 -38
  13. package/lib/client/UsageBadgeDisplaySetting.js +6 -10
  14. package/lib/client/UsageMeter.js +11 -6
  15. package/lib/client/host-contract.d.ts +1 -1
  16. package/lib/client/host-contract.js +22 -0
  17. package/lib/client/locales.d.ts +60 -0
  18. package/lib/client/locales.js +62 -2
  19. package/lib/client/provider-settings-styles.d.ts +3 -0
  20. package/lib/client/provider-settings-styles.js +206 -0
  21. package/lib/client/reset-action.d.ts +14 -0
  22. package/lib/client/reset-action.js +15 -0
  23. package/lib/client/reset-credits-view.d.ts +53 -0
  24. package/lib/client/reset-credits-view.js +103 -0
  25. package/lib/client/usage-badge-preferences.d.ts +7 -2
  26. package/lib/client/usage-badge-preferences.js +3 -2
  27. package/lib/client/usage-pace.d.ts +14 -1
  28. package/lib/client/usage-pace.js +14 -1
  29. package/lib/client.js +1036 -334
  30. package/lib/client.js.map +1 -1
  31. package/lib/index.d.ts +9 -0
  32. package/lib/index.js +1120 -602
  33. package/lib/providers/account-preferences.js +3 -2
  34. package/lib/providers/catalog-store.d.ts +3 -2
  35. package/lib/providers/catalog-store.js +57 -26
  36. package/lib/providers/claude.d.ts +22 -3
  37. package/lib/providers/claude.js +113 -15
  38. package/lib/providers/codex.d.ts +15 -2
  39. package/lib/providers/codex.js +72 -4
  40. package/lib/providers/common.d.ts +25 -0
  41. package/lib/providers/common.js +22 -18
  42. package/lib/providers/copilot.d.ts +2 -1
  43. package/lib/providers/copilot.js +10 -3
  44. package/lib/providers/grok.d.ts +2 -2
  45. package/lib/providers/grok.js +2 -2
  46. package/lib/providers/minimax-usage.d.ts +22 -1
  47. package/lib/providers/minimax-usage.js +37 -7
  48. package/lib/providers/pool-health.d.ts +2 -0
  49. package/lib/providers/pool-health.js +9 -0
  50. package/lib/providers/pool-usage.d.ts +13 -1
  51. package/lib/providers/pool-usage.js +39 -7
  52. package/lib/providers/pool.d.ts +7 -5
  53. package/lib/providers/pool.js +89 -12
  54. package/lib/providers/rate-limit.d.ts +0 -14
  55. package/lib/providers/rate-limit.js +0 -26
  56. package/lib/providers/replay.d.ts +4 -0
  57. package/lib/providers/replay.js +50 -0
  58. package/lib/providers/reset-redemption.d.ts +51 -0
  59. package/lib/providers/reset-redemption.js +102 -0
  60. package/lib/translate/anthropic.d.ts +21 -2
  61. package/lib/translate/anthropic.js +35 -11
  62. package/lib/translate/antigravity.js +13 -6
  63. package/lib/translate/chat-completions.js +1 -1
  64. package/lib/translate/resolved.d.ts +21 -0
  65. package/lib/translate/resolved.js +63 -3
  66. package/lib/translate/responses.js +8 -6
  67. package/lib/translate/sse.js +8 -0
  68. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
1
  import { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm';
2
2
  import { DISCOVERY_TIMEOUT_MS, withTimeout } from './common.js';
3
+ import { streamAccountWithReplay } from './replay.js';
3
4
  /** Reserved namespace, recognized even when malformed or no longer enabled. */
4
5
  export const ACCOUNT_MODEL_PREFIX = '~account:';
5
6
  export function accountModelId(account, model) {
@@ -140,7 +141,7 @@ export class AccountPreferencesAdapter extends LlmAdapter {
140
141
  const independent = parseAccountModelId(options.model);
141
142
  if (independent) {
142
143
  await this.requireAccount(independent.account, independent.model, true);
143
- yield* this.options.adapter.streamAccount({ ...options, model: independent.model }, independent.account);
144
+ yield* streamAccountWithReplay(this.options.adapter, options, independent.account, independent.model);
144
145
  return;
145
146
  }
146
147
  const pool = this.options.pool();
@@ -148,6 +149,6 @@ export class AccountPreferencesAdapter extends LlmAdapter {
148
149
  yield* pool.stream(options);
149
150
  return;
150
151
  }
151
- yield* this.options.adapter.streamAccount(options, await this.fallback(options.model));
152
+ yield* streamAccountWithReplay(this.options.adapter, options, await this.fallback(options.model));
152
153
  }
153
154
  }
@@ -28,8 +28,8 @@ export declare function modelsFilePath(): string;
28
28
  export declare function sanitizeSnapshot(value: unknown): CatalogSnapshot | undefined;
29
29
  /**
30
30
  * Build the durable half of one provider's catalog cache over the shared
31
- * models.json file (concurrent writers are last-writer-wins, acceptable for
32
- * a cache).
31
+ * models.json file. Saves and clears serialize with all accounts at this path
32
+ * in this process; readers see a complete file before or after an atomic rename.
33
33
  * @param provider - the provider route keying the file entry.
34
34
  * @param path - store file path; defaults to {@link modelsFilePath}.
35
35
  * @returns the persistence hooks for {@link ModelCatalogCache}.
@@ -40,6 +40,7 @@ export declare function catalogStore(provider: ProviderId, path?: string): Catal
40
40
  * entry belongs to the default account; every other account keeps its own
41
41
  * snapshot under `accounts[provider][account]`, so a working secondary login
42
42
  * survives restarts and network failures even when the default login is dead.
43
+ * Saves and clears share {@link catalogStore}'s in-process write chain.
43
44
  * @param provider - the provider route.
44
45
  * @param account - the canonical account key.
45
46
  * @param path - store file path; defaults to {@link modelsFilePath}.
@@ -12,7 +12,7 @@
12
12
  * throw on every call, which is worse than having no fallback at all.
13
13
  */
14
14
  import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
15
- import { dirname } from 'node:path';
15
+ import { dirname, resolve } from 'node:path';
16
16
  import { dshHomePath } from '@deepseek-ai/dsh-home-paths';
17
17
  import { ReasoningEffortId } from '@deepseek-ai/dsh-llm';
18
18
  /**
@@ -177,10 +177,32 @@ async function writeCatalogFile(store, path) {
177
177
  throw error;
178
178
  }
179
179
  }
180
+ /**
181
+ * Like the auth store, mutations of one resolved path share an in-process
182
+ * write chain in call order. Each reads after the preceding mutation settles;
183
+ * a failure rejects its caller without blocking later mutations. Idle chains
184
+ * are removed. Separate processes (or filesystem aliases of a path) are not
185
+ * locked and remain last-writer-wins.
186
+ */
187
+ const writeChains = new Map();
188
+ async function serialize(path, action) {
189
+ const key = resolve(path);
190
+ const previous = writeChains.get(key) ?? Promise.resolve();
191
+ const next = previous.then(action, action);
192
+ const tail = next.then(() => undefined, () => undefined);
193
+ writeChains.set(key, tail);
194
+ try {
195
+ return await next;
196
+ }
197
+ finally {
198
+ if (writeChains.get(key) === tail)
199
+ writeChains.delete(key);
200
+ }
201
+ }
180
202
  /**
181
203
  * Build the durable half of one provider's catalog cache over the shared
182
- * models.json file (concurrent writers are last-writer-wins, acceptable for
183
- * a cache).
204
+ * models.json file. Saves and clears serialize with all accounts at this path
205
+ * in this process; readers see a complete file before or after an atomic rename.
184
206
  * @param provider - the provider route keying the file entry.
185
207
  * @param path - store file path; defaults to {@link modelsFilePath}.
186
208
  * @returns the persistence hooks for {@link ModelCatalogCache}.
@@ -191,16 +213,20 @@ export function catalogStore(provider, path = modelsFilePath()) {
191
213
  return sanitizeSnapshot((await readCatalogFile(path))[provider]);
192
214
  },
193
215
  async save(snapshot) {
194
- const store = await readCatalogFile(path);
195
- store[provider] = snapshot;
196
- await writeCatalogFile(store, path);
216
+ return serialize(path, async () => {
217
+ const store = await readCatalogFile(path);
218
+ store[provider] = snapshot;
219
+ await writeCatalogFile(store, path);
220
+ });
197
221
  },
198
222
  async clear() {
199
- const store = await readCatalogFile(path);
200
- if (store[provider] === undefined)
201
- return;
202
- delete store[provider];
203
- await writeCatalogFile(store, path);
223
+ return serialize(path, async () => {
224
+ const store = await readCatalogFile(path);
225
+ if (store[provider] === undefined)
226
+ return;
227
+ delete store[provider];
228
+ await writeCatalogFile(store, path);
229
+ });
204
230
  },
205
231
  };
206
232
  }
@@ -209,6 +235,7 @@ export function catalogStore(provider, path = modelsFilePath()) {
209
235
  * entry belongs to the default account; every other account keeps its own
210
236
  * snapshot under `accounts[provider][account]`, so a working secondary login
211
237
  * survives restarts and network failures even when the default login is dead.
238
+ * Saves and clears share {@link catalogStore}'s in-process write chain.
212
239
  * @param provider - the provider route.
213
240
  * @param account - the canonical account key.
214
241
  * @param path - store file path; defaults to {@link modelsFilePath}.
@@ -221,23 +248,27 @@ export function accountCatalogStore(provider, account, path = modelsFilePath())
221
248
  return section !== undefined && Object.hasOwn(section, account) ? sanitizeSnapshot(section[account]) : undefined;
222
249
  },
223
250
  async save(snapshot) {
224
- const store = await readCatalogFile(path);
225
- const accounts = typeof store.accounts === 'object' && store.accounts !== null && !Array.isArray(store.accounts)
226
- ? store.accounts
227
- : {};
228
- const section = { ...accountSection(store, provider) ?? {}, [account]: snapshot };
229
- store.accounts = { ...accounts, [provider]: section };
230
- await writeCatalogFile(store, path);
251
+ return serialize(path, async () => {
252
+ const store = await readCatalogFile(path);
253
+ const accounts = typeof store.accounts === 'object' && store.accounts !== null && !Array.isArray(store.accounts)
254
+ ? store.accounts
255
+ : {};
256
+ const section = { ...accountSection(store, provider) ?? {}, [account]: snapshot };
257
+ store.accounts = { ...accounts, [provider]: section };
258
+ await writeCatalogFile(store, path);
259
+ });
231
260
  },
232
261
  async clear() {
233
- const store = await readCatalogFile(path);
234
- const section = accountSection(store, provider);
235
- if (section === undefined || !Object.hasOwn(section, account))
236
- return;
237
- const rest = { ...section };
238
- delete rest[account];
239
- store.accounts = { ...store.accounts, [provider]: rest };
240
- await writeCatalogFile(store, path);
262
+ return serialize(path, async () => {
263
+ const store = await readCatalogFile(path);
264
+ const section = accountSection(store, provider);
265
+ if (section === undefined || !Object.hasOwn(section, account))
266
+ return;
267
+ const rest = { ...section };
268
+ delete rest[account];
269
+ store.accounts = { ...store.accounts, [provider]: rest };
270
+ await writeCatalogFile(store, path);
271
+ });
241
272
  },
242
273
  };
243
274
  }
@@ -10,6 +10,7 @@ import type { ClaudeSession } from '../auth/store.js';
10
10
  import type { PoolAdapter } from './pool.js';
11
11
  import type { AttachmentStore } from '@deepseek-ai/dsh-attachment';
12
12
  import type { TranslatableMessage } from '../translate/resolved.js';
13
+ import type { PromptCacheTtl } from '../translate/anthropic.js';
13
14
  import { AccountTokenManager } from './accounts.js';
14
15
  import type { CatalogPersistence, DiscoveredModel, FetchFn, ModelEntry, ProviderUsage } from './common.js';
15
16
  import type { CliVersion } from './npm-cli-version.js';
@@ -34,8 +35,8 @@ export declare const CLAUDE_PREEMPT_MS: number;
34
35
  * headers are deliberately not read: they are rollover snapshots attached to
35
36
  * every response, so on a 429 they cannot say which bucket refused, and the
36
37
  * earliest of them is typically the bucket that still had room — a wait that
37
- * lands straight back in the closed window. They reach the operator through
38
- * `rateLimitDiagnostics` instead.
38
+ * lands straight back in the closed window. The parsed reset supplies the retry
39
+ * hint; diagnostics omit raw provider headers and body text.
39
40
  */
40
41
  export declare const claudeRateLimitReset: RateLimitResetReader;
41
42
  /**
@@ -86,6 +87,10 @@ export declare const CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usa
86
87
  * source of Claude Code's `/usage` screen). Newer responses carry a
87
88
  * structured `limits` array; older ones the flat `five_hour`/`seven_day*`
88
89
  * buckets — both shapes are read, the array winning when it has entries.
90
+ * Requests the optional cedar_ember banked-reset block with the existing CLI
91
+ * identity. A 400/403 retries once without that parameter; ordinary windows
92
+ * remain available and resetCreditsError records the optional lookup failure.
93
+ * Missing/ineligible blocks and empty or unusable grants expose no credits.
89
94
  * @param session - the stored session (used as-is; never refreshed here).
90
95
  * @param fetchFn - fetch implementation (injectable for tests).
91
96
  * @param signal - caller cancellation from the RPC transport.
@@ -124,7 +129,17 @@ export interface ClaudeAdapterOptions {
124
129
  defaultEffortOf?: (model: string) => string | undefined;
125
130
  /** Resolves the Claude Code version to present (npm-backed); absent means the local floor. */
126
131
  resolveCliVersion?: () => Promise<string>;
132
+ /** Cache breakpoint lifetime; absent means the API's five-minute default. */
133
+ promptCacheTtl?: PromptCacheTtl;
127
134
  }
135
+ /**
136
+ * Inline image data a Claude request may carry, in base64 bytes. Every turn
137
+ * resends the history, so screenshots accumulate until the request passes the
138
+ * API's 32MB body limit and every later turn and compaction fails with HTTP
139
+ * 413 `request_too_large`. Past this budget the host offloads the oldest
140
+ * images to text and retries; the remaining 12MB holds prompts, tools, and text.
141
+ */
142
+ export declare const CLAUDE_REQUEST_IMAGE_BUDGET: number;
128
143
  /**
129
144
  * Assemble the Anthropic request body.
130
145
  *
@@ -138,9 +153,13 @@ export interface ClaudeAdapterOptions {
138
153
  * @param maxTokens - the resolved output cap.
139
154
  * @param thinking - the thinking parameter, when the model takes one.
140
155
  * @param effort - the reasoning effort, when the model advertises efforts.
156
+ * @param cacheTtl - lifetime of every cache breakpoint; defaults to five minutes.
157
+ * Requests that carry a `purpose` (compaction, session titles) always use five
158
+ * minutes to preserve their existing auxiliary-request behavior. A five-minute
159
+ * mark can still read an entry a one-hour mark wrote.
141
160
  * @returns the JSON body to POST.
142
161
  */
143
- export declare function claudeRequestBody(options: GenerateOptions, messages: readonly TranslatableMessage[], maxTokens: number, thinking?: Record<string, unknown>, effort?: string): Record<string, unknown>;
162
+ export declare function claudeRequestBody(options: GenerateOptions, messages: readonly TranslatableMessage[], maxTokens: number, thinking?: Record<string, unknown>, effort?: string, cacheTtl?: PromptCacheTtl): Record<string, unknown>;
144
163
  /** Claude wire adapter: one instance serves the `claude` provider route. */
145
164
  export declare class ClaudeAdapter extends LlmAdapter {
146
165
  private readonly options;
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { execFileSync } from 'node:child_process';
7
7
  import { EMPTY_RESPONSE_CODE, errorChain, LlmAdapter, LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
8
- import { resolveImages } from '../translate/resolved.js';
8
+ import { hostSupportsImageOffload, IMAGE_OFFLOAD_REQUIRED, requiredImageOffloadCount, resolveImages } from '../translate/resolved.js';
9
9
  import { markMessageCache, streamAnthropic, toAnthropicMessages, toAnthropicSystem, toAnthropicTools, } from '../translate/anthropic.js';
10
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';
@@ -40,8 +40,8 @@ const CLAUDE_RESET_FIELDS = ['resets_at', 'resetsAt', 'reset_at', 'retry_after']
40
40
  * headers are deliberately not read: they are rollover snapshots attached to
41
41
  * every response, so on a 429 they cannot say which bucket refused, and the
42
42
  * earliest of them is typically the bucket that still had room — a wait that
43
- * lands straight back in the closed window. They reach the operator through
44
- * `rateLimitDiagnostics` instead.
43
+ * lands straight back in the closed window. The parsed reset supplies the retry
44
+ * hint; diagnostics omit raw provider headers and body text.
45
45
  */
46
46
  export const claudeRateLimitReset = (response, body, now) => {
47
47
  const unified = earliestReset(resetInstantFromHeader(response, 'anthropic-ratelimit-unified-reset', now), resetInstantFromHeader(response, 'anthropic-ratelimit-unified-fallback-reset', now));
@@ -249,12 +249,70 @@ export function isClaudePermanentRefreshError(error) {
249
249
  && (error.oauthCode === 'invalid_grant' || error.oauthCode === 'invalid_token');
250
250
  }
251
251
  export const CLAUDE_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
252
- /** RFC3339 `resets_at` value → epoch ms, or undefined when absent/unparsable. */
252
+ /** ISO-8601 or numeric epoch (seconds below 1e10 in magnitude, otherwise ms). */
253
253
  function claudeResetsAt(value) {
254
- if (typeof value !== 'string' || value.length === 0)
254
+ let parsed;
255
+ if (typeof value === 'number') {
256
+ parsed = Math.trunc(Math.abs(value) < 1e10 ? value * 1000 : value);
257
+ }
258
+ else if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
259
+ parsed = Date.parse(value);
260
+ }
261
+ else {
255
262
  return undefined;
256
- const parsed = Date.parse(value);
257
- return Number.isFinite(parsed) ? parsed : undefined;
263
+ }
264
+ return Number.isFinite(parsed) && Number.isFinite(new Date(parsed).getTime()) ? parsed : undefined;
265
+ }
266
+ /**
267
+ * Read the observed cedar_ember shape from ItsJazii/pane, not an official
268
+ * contract. Each entry retains its grant id and counts; claimable is only
269
+ * read-only server-selection metadata, never an implemented redemption path.
270
+ */
271
+ function claudeResetCredits(value, now) {
272
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
273
+ return undefined;
274
+ const ember = value;
275
+ if (ember.eligible !== true || !Array.isArray(ember.grants))
276
+ return undefined;
277
+ const cooldownUntil = claudeResetsAt(ember.cooldown_until);
278
+ // An unparseable disclosed cooldown must not assert that claims are available.
279
+ const cooling = cooldownUntil === undefined
280
+ ? ember.cooldown_until !== undefined && ember.cooldown_until !== null
281
+ : cooldownUntil > now;
282
+ const credits = [];
283
+ let selected = false;
284
+ for (const raw of ember.grants) {
285
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
286
+ continue;
287
+ const grant = raw;
288
+ const { id, resets_total: total, resets_left: left, usable_now: usable, paused } = grant;
289
+ const expiresAt = claudeResetsAt(grant.ends_at);
290
+ if (typeof id !== 'string' || id.trim().length === 0
291
+ || typeof total !== 'number' || !Number.isSafeInteger(total) || total < 1
292
+ || typeof left !== 'number' || !Number.isSafeInteger(left)
293
+ || expiresAt === undefined
294
+ || (usable !== undefined && typeof usable !== 'boolean')
295
+ || (paused !== undefined && typeof paused !== 'boolean'))
296
+ continue;
297
+ const resetsLeft = Math.max(0, Math.min(left, total));
298
+ if (resetsLeft === 0)
299
+ continue;
300
+ const claimable = !selected && id === ember.next_grant_id && usable === true
301
+ && paused !== true && !cooling && expiresAt > now;
302
+ if (claimable)
303
+ selected = true;
304
+ credits.push({
305
+ id,
306
+ expiresAt,
307
+ resetsTotal: total,
308
+ resetsLeft,
309
+ ...usable === undefined ? {} : { usableNow: usable },
310
+ ...paused === undefined ? {} : { paused },
311
+ claimable,
312
+ ...cooldownUntil === undefined ? {} : { cooldownUntil },
313
+ });
314
+ }
315
+ return credits.length > 0 ? credits : undefined;
258
316
  }
259
317
  /** Claude session and weekly buckets reset as whole five-hour/seven-day windows. */
260
318
  function claudeWindowTiming(kind) {
@@ -308,6 +366,10 @@ function claudeLimitsWindows(value) {
308
366
  * source of Claude Code's `/usage` screen). Newer responses carry a
309
367
  * structured `limits` array; older ones the flat `five_hour`/`seven_day*`
310
368
  * buckets — both shapes are read, the array winning when it has entries.
369
+ * Requests the optional cedar_ember banked-reset block with the existing CLI
370
+ * identity. A 400/403 retries once without that parameter; ordinary windows
371
+ * remain available and resetCreditsError records the optional lookup failure.
372
+ * Missing/ineligible blocks and empty or unusable grants expose no credits.
311
373
  * @param session - the stored session (used as-is; never refreshed here).
312
374
  * @param fetchFn - fetch implementation (injectable for tests).
313
375
  * @param signal - caller cancellation from the RPC transport.
@@ -316,7 +378,7 @@ function claudeLimitsWindows(value) {
316
378
  */
317
379
  export async function fetchClaudeUsage(session, fetchFn = hostFetch, signal, cliVersion = localClaudeCliVersion) {
318
380
  const userAgent = claudeCliUserAgent(await cliVersion());
319
- const response = await fetchFn(CLAUDE_USAGE_URL, {
381
+ const init = {
320
382
  headers: {
321
383
  'authorization': `Bearer ${session.accessToken}`,
322
384
  'anthropic-beta': 'oauth-2025-04-20',
@@ -326,13 +388,25 @@ export async function fetchClaudeUsage(session, fetchFn = hostFetch, signal, cli
326
388
  'accept': 'application/json',
327
389
  },
328
390
  ...signal === undefined ? {} : { signal },
329
- });
391
+ };
392
+ let response = await fetchFn(`${CLAUDE_USAGE_URL}?cedar_ember=1`, init);
393
+ let resetCreditsError;
394
+ if (response.status === 400 || response.status === 403) {
395
+ resetCreditsError = `Claude banked resets lookup failed (HTTP ${response.status}); using plain usage.`;
396
+ // Optional program support must not turn a working usage endpoint into an error.
397
+ response = await fetchFn(CLAUDE_USAGE_URL, init);
398
+ }
330
399
  if (!response.ok)
331
400
  throw await oauthEndpointError(response, 'claude usage');
332
401
  const payload = await response.json();
402
+ const resetCredits = resetCreditsError === undefined ? claudeResetCredits(payload.cedar_ember, Date.now()) : undefined;
403
+ const resets = {
404
+ ...resetCredits === undefined ? {} : { resetCredits },
405
+ ...resetCreditsError === undefined ? {} : { resetCreditsError },
406
+ };
333
407
  const modern = claudeLimitsWindows(payload.limits);
334
408
  if (modern.length > 0)
335
- return { supported: true, windows: modern };
409
+ return { supported: true, windows: modern, ...resets };
336
410
  const windows = [];
337
411
  const legacy = [
338
412
  claudeLegacyWindow(payload.five_hour, 'session'),
@@ -344,7 +418,7 @@ export async function fetchClaudeUsage(session, fetchFn = hostFetch, signal, cli
344
418
  if (window !== undefined)
345
419
  windows.push(window);
346
420
  }
347
- return { supported: true, windows };
421
+ return { supported: true, windows, ...resets };
348
422
  }
349
423
  function claudeThinkingType(capabilities) {
350
424
  const types = capabilities?.thinking?.types;
@@ -431,6 +505,14 @@ function claudeMaxTokens(configured, disc) {
431
505
  * this cap; 3.75MB encoded stays under the 5MB base64 per-image limit.
432
506
  */
433
507
  const CLAUDE_IMAGE_LIMIT = { maxEdge: 2000, maxBytes: 3_750_000 };
508
+ /**
509
+ * Inline image data a Claude request may carry, in base64 bytes. Every turn
510
+ * resends the history, so screenshots accumulate until the request passes the
511
+ * API's 32MB body limit and every later turn and compaction fails with HTTP
512
+ * 413 `request_too_large`. Past this budget the host offloads the oldest
513
+ * images to text and retries; the remaining 12MB holds prompts, tools, and text.
514
+ */
515
+ export const CLAUDE_REQUEST_IMAGE_BUDGET = 20 * 1024 * 1024;
434
516
  /** The Claude 4.5 family accepts image input. */
435
517
  const CLAUDE_MODALITIES = ['text', 'image'];
436
518
  /**
@@ -446,15 +528,23 @@ const CLAUDE_MODALITIES = ['text', 'image'];
446
528
  * @param maxTokens - the resolved output cap.
447
529
  * @param thinking - the thinking parameter, when the model takes one.
448
530
  * @param effort - the reasoning effort, when the model advertises efforts.
531
+ * @param cacheTtl - lifetime of every cache breakpoint; defaults to five minutes.
532
+ * Requests that carry a `purpose` (compaction, session titles) always use five
533
+ * minutes to preserve their existing auxiliary-request behavior. A five-minute
534
+ * mark can still read an entry a one-hour mark wrote.
449
535
  * @returns the JSON body to POST.
450
536
  */
451
- export function claudeRequestBody(options, messages, maxTokens, thinking, effort) {
537
+ export function claudeRequestBody(options, messages, maxTokens, thinking, effort, cacheTtl) {
538
+ // Read structurally so older host types need not declare `purpose`. Hosts
539
+ // that do not classify auxiliary calls follow the configured TTL.
540
+ const purpose = options.purpose;
541
+ const ttl = purpose === undefined ? cacheTtl : undefined;
452
542
  const anthropicMessages = toAnthropicMessages(messages);
453
- markMessageCache(anthropicMessages);
543
+ markMessageCache(anthropicMessages, ttl);
454
544
  return {
455
545
  model: options.model,
456
546
  max_tokens: maxTokens,
457
- system: toAnthropicSystem(options.system, messages),
547
+ system: toAnthropicSystem(options.system, messages, ttl),
458
548
  messages: anthropicMessages,
459
549
  ...options.tools !== undefined && options.tools.length > 0
460
550
  ? { tools: toAnthropicTools(options.tools) }
@@ -659,6 +749,14 @@ export class ClaudeAdapter extends LlmAdapter {
659
749
  }
660
750
  async request(options, session, signal) {
661
751
  const messages = await resolveImages(options.messages, this.options.resolveAttachments?.(), signal, CLAUDE_IMAGE_LIMIT);
752
+ if (hostSupportsImageOffload()) {
753
+ const offloadImages = requiredImageOffloadCount(messages, CLAUDE_REQUEST_IMAGE_BUDGET);
754
+ if (offloadImages > 0) {
755
+ // Older dsh-llm option types lack `offloadImages`; 0.2+ hosts read it.
756
+ throw new LlmError(`claude request images exceed the ${CLAUDE_REQUEST_IMAGE_BUDGET}-byte base64 budget; `
757
+ + `${offloadImages} more oldest image(s) must be offloaded`, IMAGE_OFFLOAD_REQUIRED, { offloadImages });
758
+ }
759
+ }
662
760
  const disc = await this.discovered(options.model);
663
761
  const maxTokens = options.maxTokens
664
762
  ?? claudeMaxTokens(this.options.models.find(entry => entry.id === options.model), disc);
@@ -666,7 +764,7 @@ export class ClaudeAdapter extends LlmAdapter {
666
764
  const effort = options.reasoningEffort !== undefined && disc?.reasoning !== undefined
667
765
  ? String(options.reasoningEffort)
668
766
  : undefined;
669
- const body = claudeRequestBody(options, messages, maxTokens, thinking, effort);
767
+ const body = claudeRequestBody(options, messages, maxTokens, thinking, effort, this.options.promptCacheTtl);
670
768
  const cliVersion = await (this.options.resolveCliVersion ?? localClaudeCliVersion)();
671
769
  return hostFetch(CLAUDE_API_URL, {
672
770
  method: 'POST',
@@ -26,8 +26,8 @@ export declare const CODEX_PREEMPT_MS: number;
26
26
  * rollover snapshots the backend attaches to every response, one per window,
27
27
  * so they say nothing about which window refused: a burst 429 that would clear
28
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.
29
+ * park the turn for those hours. The parsed body reset supplies the retry hint;
30
+ * diagnostics omit raw provider headers and body text.
31
31
  */
32
32
  export declare const codexRateLimitReset: RateLimitResetReader;
33
33
  /**
@@ -77,6 +77,11 @@ export declare function refreshCodex(session: CodexSession): Promise<CodexSessio
77
77
  */
78
78
  export declare function isCodexPermanentRefreshError(error: unknown): boolean;
79
79
  export declare const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
80
+ export declare const CODEX_RESET_CREDITS_URL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits";
81
+ /** Read banked resets without spending them; consumption uses a separate explicit POST. */
82
+ export declare function fetchCodexResetCredits(session: CodexSession, fetchFn?: FetchFn, signal?: AbortSignal): Promise<NonNullable<ProviderUsage['resetCredits']>>;
83
+ /** Submit one explicit reset; ambiguous or unconfirmed responses throw without retry. */
84
+ export declare function consumeCodexResetCredit(session: CodexSession, creditId: string, requestId: string, fetchFn?: FetchFn, signal?: AbortSignal): Promise<void>;
80
85
  /**
81
86
  * Fetch the codex subscription usage from the ChatGPT backend wham/usage
82
87
  * endpoint (the source of the codex CLI `/status` rate-limit lines). The
@@ -164,6 +169,14 @@ export declare const CODEX_UNKNOWN_TOOL_OUTCOME = "The tool call has no recorded
164
169
  * @returns the same array when already balanced, otherwise a repaired array.
165
170
  */
166
171
  export declare function reconcileResponsesToolCalls(input: ResponsesRequestInput['input']): ResponsesRequestInput['input'];
172
+ /**
173
+ * The `reasoning.effort` wire value for a selected effort. Codex's "ultra" is
174
+ * a client-side selection (max reasoning plus proactive sub-agent delegation)
175
+ * that the Codex CLI itself lowers to `max` at the Responses API boundary;
176
+ * the backend answers HTTP 400 to a literal `ultra`. Discovery no longer
177
+ * lists it, but an effort saved before that change may still arrive here.
178
+ */
179
+ export declare function codexWireEffort(effort: string): string;
167
180
  /**
168
181
  * The Responses request body for one generation. A fast-tier request (the
169
182
  * composer Speed toggle, the codex CLI's fast mode) carries
@@ -35,8 +35,8 @@ const CODEX_RESET_FIELDS = ['resets_in_seconds', 'reset_after_seconds', 'resets_
35
35
  * rollover snapshots the backend attaches to every response, one per window,
36
36
  * so they say nothing about which window refused: a burst 429 that would clear
37
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.
38
+ * park the turn for those hours. The parsed body reset supplies the retry hint;
39
+ * diagnostics omit raw provider headers and body text.
40
40
  */
41
41
  export const codexRateLimitReset = (_response, body, now) => resetFromFields(jsonBody(body), CODEX_RESET_FIELDS, now);
42
42
  /** Default instruction when the request carries no system prompt. */
@@ -217,6 +217,62 @@ export function isCodexPermanentRefreshError(error) {
217
217
  && PERMANENT_REFRESH_CODES.has(error.oauthCode);
218
218
  }
219
219
  export const CODEX_USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage';
220
+ export const CODEX_RESET_CREDITS_URL = 'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits';
221
+ function parseResetDate(value) {
222
+ if (typeof value !== 'string')
223
+ return undefined;
224
+ const timestamp = Date.parse(value);
225
+ return Number.isFinite(timestamp) ? timestamp : undefined;
226
+ }
227
+ /** Read banked resets without spending them; consumption uses a separate explicit POST. */
228
+ export async function fetchCodexResetCredits(session, fetchFn = hostFetch, signal) {
229
+ const response = await fetchFn(CODEX_RESET_CREDITS_URL, {
230
+ headers: {
231
+ authorization: `Bearer ${session.accessToken}`,
232
+ 'chatgpt-account-id': session.accountId,
233
+ originator: 'Codex Desktop',
234
+ 'openai-beta': 'codex-1',
235
+ accept: 'application/json',
236
+ ...attributionHeaders(),
237
+ },
238
+ ...signal === undefined ? {} : { signal },
239
+ });
240
+ if (!response.ok)
241
+ throw await oauthEndpointError(response, 'codex reset credits');
242
+ const payload = await response.json();
243
+ if (!Array.isArray(payload?.credits))
244
+ throw new Error('codex reset credits: unexpected response (missing credits array)');
245
+ return payload.credits.flatMap((value) => {
246
+ if (typeof value !== 'object' || value === null)
247
+ return [];
248
+ const credit = value;
249
+ if (credit.status !== 'available' || credit.reset_type !== 'codex_rate_limits')
250
+ return [];
251
+ const grantedAt = parseResetDate(credit.granted_at);
252
+ const expiresAt = parseResetDate(credit.expires_at);
253
+ return [{ ...(typeof credit.id === 'string' ? { id: credit.id } : {}), ...(grantedAt === undefined ? {} : { grantedAt }), ...(expiresAt === undefined ? {} : { expiresAt }) }];
254
+ });
255
+ }
256
+ /** Submit one explicit reset; ambiguous or unconfirmed responses throw without retry. */
257
+ export async function consumeCodexResetCredit(session, creditId, requestId, fetchFn = hostFetch, signal) {
258
+ const response = await fetchFn(CODEX_RESET_CREDITS_URL + '/consume', {
259
+ method: 'POST',
260
+ headers: {
261
+ authorization: 'Bearer ' + session.accessToken,
262
+ 'chatgpt-account-id': session.accountId,
263
+ originator: 'Codex Desktop', 'openai-beta': 'codex-1',
264
+ 'content-type': 'application/json', accept: 'application/json',
265
+ ...attributionHeaders(),
266
+ },
267
+ body: JSON.stringify({ redeem_request_id: requestId, credit_id: creditId }),
268
+ ...signal === undefined ? {} : { signal },
269
+ });
270
+ if (!response.ok)
271
+ throw await oauthEndpointError(response, 'codex consume reset');
272
+ const payload = await response.json();
273
+ if (payload?.code !== 'reset')
274
+ throw new Error('Reset was not confirmed. Verify the account in Codex before retrying.');
275
+ }
220
276
  /** Seconds of the canonical 5-hour session and 7-day weekly windows. */
221
277
  const SESSION_WINDOW_SECONDS = 5 * 60 * 60;
222
278
  const WEEKLY_WINDOW_SECONDS = 7 * 24 * 60 * 60;
@@ -365,8 +421,10 @@ export async function fetchCodexModels(session, fetchFn = hostFetch, signal, cli
365
421
  // dropped, and an absent or unknown value is included (in doubt, include).
366
422
  if (entry.visibility === 'hide' || entry.visibility === 'none')
367
423
  continue;
424
+ // "ultra" is a Codex-client mode (max reasoning + automatic sub-agent
425
+ // delegation), not a wire value: the Responses API rejects it with HTTP 400.
368
426
  const efforts = (entry.supported_reasoning_levels ?? [])
369
- .filter(level => typeof level.effort === 'string' && level.effort.length > 0)
427
+ .filter(level => typeof level.effort === 'string' && level.effort.length > 0 && level.effort !== 'ultra')
370
428
  .map(level => ({
371
429
  id: ReasoningEffortId(level.effort),
372
430
  name: effortDisplayName(level.effort),
@@ -491,6 +549,16 @@ export function reconcileResponsesToolCalls(input) {
491
549
  }
492
550
  return balanced;
493
551
  }
552
+ /**
553
+ * The `reasoning.effort` wire value for a selected effort. Codex's "ultra" is
554
+ * a client-side selection (max reasoning plus proactive sub-agent delegation)
555
+ * that the Codex CLI itself lowers to `max` at the Responses API boundary;
556
+ * the backend answers HTTP 400 to a literal `ultra`. Discovery no longer
557
+ * lists it, but an effort saved before that change may still arrive here.
558
+ */
559
+ export function codexWireEffort(effort) {
560
+ return effort === 'ultra' ? 'max' : effort;
561
+ }
494
562
  /**
495
563
  * The Responses request body for one generation. A fast-tier request (the
496
564
  * composer Speed toggle, the codex CLI's fast mode) carries
@@ -509,7 +577,7 @@ export function codexRequestBody(options, resolved, fast) {
509
577
  ? { tools: toResponsesTools(options.tools, { strict: false }), tool_choice: 'auto', parallel_tool_calls: true }
510
578
  : {},
511
579
  ...options.reasoningEffort !== undefined
512
- ? { reasoning: { effort: String(options.reasoningEffort), summary: 'auto' } }
580
+ ? { reasoning: { effort: codexWireEffort(String(options.reasoningEffort)), summary: 'auto' } }
513
581
  : {},
514
582
  store: false,
515
583
  stream: true,
@@ -200,6 +200,27 @@ export interface UsageWindow {
200
200
  /** Provider-verified fixed interval; permits deriving start from end and duration. */
201
201
  fixedWindow?: boolean;
202
202
  }
203
+ /** One banked reset credit (Codex), or one grant containing resets (Claude). */
204
+ export interface ResetCredit {
205
+ id?: string;
206
+ grantedAt?: number;
207
+ /** Expiration in epoch ms; never a recurring usage-window reset. */
208
+ expiresAt?: number;
209
+ /** Claude grant counts; one array entry per grant, not per remaining reset. */
210
+ resetsTotal?: number;
211
+ resetsLeft?: number;
212
+ /** Claude's disclosed grant state, absent when the server omits it. */
213
+ usableNow?: boolean;
214
+ paused?: boolean;
215
+ /**
216
+ * Claude server-selected grant is currently usable. Read-only metadata, not
217
+ * permission to redeem; no Claude redemption endpoint is implemented.
218
+ * Codex entries omit this field and retain their existing semantics.
219
+ */
220
+ claimable?: boolean;
221
+ /** Claude program cooldown in epoch ms, repeated on each grant when disclosed. */
222
+ cooldownUntil?: number;
223
+ }
203
224
  /** Subscription usage of one provider, as served by the `usage` RPC endpoint. */
204
225
  export interface ProviderUsage {
205
226
  /** Original successful observation time, preserved across cache reads. */
@@ -212,6 +233,10 @@ export interface ProviderUsage {
212
233
  windows?: UsageWindow[];
213
234
  /** Plan name the usage endpoint reported, when present. */
214
235
  plan?: string;
236
+ /** Banked Codex resets or Claude grants, when the provider discloses them. */
237
+ resetCredits?: ResetCredit[];
238
+ /** Optional reset lookup failure; ordinary usage remains usable. */
239
+ resetCreditsError?: string;
215
240
  }
216
241
  /** One model discovered from a provider's live model-list endpoint. */
217
242
  export interface DiscoveredModel {