dsh-subscriptions 0.1.3 → 0.1.5

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 (75) hide show
  1. package/README.md +64 -0
  2. package/lib/auth/device-flow.js +6 -3
  3. package/lib/auth/oauth-flow.js +41 -8
  4. package/lib/auth/rpc.d.ts +3 -1
  5. package/lib/auth/rpc.js +10 -1
  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.js +6 -5
  10. package/lib/client/SubscriptionsSection.d.ts +21 -73
  11. package/lib/client/SubscriptionsSection.js +130 -104
  12. package/lib/client/UsageBadgeDisplaySetting.js +4 -8
  13. package/lib/client/host-contract.d.ts +1 -1
  14. package/lib/client/host-contract.js +22 -0
  15. package/lib/client/locales.d.ts +58 -0
  16. package/lib/client/locales.js +58 -0
  17. package/lib/client/provider-settings-styles.d.ts +3 -0
  18. package/lib/client/provider-settings-styles.js +206 -0
  19. package/lib/client/reset-action.d.ts +14 -0
  20. package/lib/client/reset-action.js +15 -0
  21. package/lib/client/reset-credits-view.d.ts +53 -0
  22. package/lib/client/reset-credits-view.js +103 -0
  23. package/lib/client.js +937 -310
  24. package/lib/client.js.map +1 -1
  25. package/lib/index.d.ts +9 -0
  26. package/lib/index.js +6058 -5298
  27. package/lib/model-defaults.d.ts +4 -5
  28. package/lib/model-defaults.js +18 -14
  29. package/lib/providers/account-preferences.js +3 -2
  30. package/lib/providers/antigravity.d.ts +0 -2
  31. package/lib/providers/antigravity.js +5 -9
  32. package/lib/providers/catalog-store.d.ts +3 -2
  33. package/lib/providers/catalog-store.js +57 -26
  34. package/lib/providers/claude.d.ts +22 -3
  35. package/lib/providers/claude.js +118 -20
  36. package/lib/providers/codex-search.js +5 -2
  37. package/lib/providers/codex.d.ts +15 -2
  38. package/lib/providers/codex.js +77 -9
  39. package/lib/providers/common.d.ts +38 -0
  40. package/lib/providers/common.js +44 -18
  41. package/lib/providers/copilot.d.ts +2 -1
  42. package/lib/providers/copilot.js +13 -6
  43. package/lib/providers/cursor-adapter.d.ts +35 -0
  44. package/lib/providers/cursor-adapter.js +245 -4
  45. package/lib/providers/cursor-auth.js +11 -4
  46. package/lib/providers/cursor-usage.js +2 -1
  47. package/lib/providers/external-usage.js +2 -1
  48. package/lib/providers/grok.d.ts +2 -2
  49. package/lib/providers/grok.js +16 -11
  50. package/lib/providers/minimax-usage.js +2 -1
  51. package/lib/providers/pool-health.d.ts +2 -0
  52. package/lib/providers/pool-health.js +9 -0
  53. package/lib/providers/pool-usage.d.ts +13 -1
  54. package/lib/providers/pool-usage.js +39 -7
  55. package/lib/providers/pool.d.ts +7 -5
  56. package/lib/providers/pool.js +89 -12
  57. package/lib/providers/rate-limit.d.ts +0 -14
  58. package/lib/providers/rate-limit.js +0 -26
  59. package/lib/providers/replay.d.ts +4 -0
  60. package/lib/providers/replay.js +50 -0
  61. package/lib/providers/reset-redemption.d.ts +51 -0
  62. package/lib/providers/reset-redemption.js +102 -0
  63. package/lib/tools/image-generate.js +2 -1
  64. package/lib/tools/video-generate.d.ts +0 -5
  65. package/lib/tools/video-generate.js +25 -14
  66. package/lib/tools/x-search.js +2 -2
  67. package/lib/translate/anthropic.d.ts +21 -2
  68. package/lib/translate/anthropic.js +35 -11
  69. package/lib/translate/antigravity.js +13 -6
  70. package/lib/translate/chat-completions.js +1 -1
  71. package/lib/translate/resolved.d.ts +24 -0
  72. package/lib/translate/resolved.js +66 -3
  73. package/lib/translate/responses.js +8 -6
  74. package/lib/translate/sse.js +8 -0
  75. package/package.json +1 -1
@@ -6,12 +6,11 @@ export type ModelDefaults = Readonly<Partial<Record<ProviderId, ModelDefaultMap>
6
6
  /** Absolute path of the defaults file. */
7
7
  export declare function modelDefaultsFilePath(): string;
8
8
  /**
9
- * The last load failure, or a warning about entries that were skipped while
10
- * loading; consumers only use it for diagnostics.
9
+ * Ready the defaults store.
10
+ * index.ts calls it at apply time so every later synchronous read sees the
11
+ * persisted state; store tests also call it after resetting the singleton.
11
12
  */
12
- export declare function modelDefaultsLoadError(): unknown;
13
- /** A detached snapshot for the RPC surface (render + diffing). */
14
- export declare function modelDefaultsSnapshot(): ModelDefaults;
13
+ export declare function loadModelDefaults(): Promise<void>;
15
14
  /**
16
15
  * Set or clear one model's configured default effort, then persist. The
17
16
  * memory snapshot updates only after the atomic write succeeds, so a failed
@@ -8,10 +8,10 @@
8
8
  * chip the model picker shows when the discovered catalog advertises no
9
9
  * default at all.
10
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
11
+ * Writes are single-process and atomic. The account manager submits effort
12
+ * edits sequentially and disables its editor while saving, but concurrent RPC
13
+ * callers can still overlap. The write chain below serialises them, so no
14
+ * update is lost to a read-modify-write race. Every read comes from the
15
15
  * in-memory snapshot, so the on-disk file only needs to survive a restart: a
16
16
  * malformed file reads as empty and is rewritten on the next save, never
17
17
  * taking the plugin down with it.
@@ -29,21 +29,21 @@ const EMPTY = Object.freeze({});
29
29
  let current = EMPTY;
30
30
  /** One lazy load of the on-disk file (read once per process). */
31
31
  let ready;
32
- /** Last load failure, surfaced to callers that care; defaults stay empty. */
32
+ /** Last load failure or skipped-entry warning, inspected only by tests. */
33
33
  let loadError;
34
34
  /**
35
35
  * Serialises every write: the read-modify-write sequence must not interleave,
36
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).
37
+ * and silently drop the first update. Concurrent RPC callers can overlap
38
+ * even though one account-manager dialog submits its edits sequentially.
39
39
  */
40
40
  let writeChain = Promise.resolve();
41
41
  /**
42
42
  * Validate one persisted provider section: a string→string map, or undefined.
43
43
  * Malformed *entries* are skipped, not the whole section: one bad value (a
44
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.
45
+ * that provider. Dropped model ids are recorded in a test-only load warning;
46
+ * production callers do not surface this warning.
47
47
  */
48
48
  function sanitizeProvider(value, dropped) {
49
49
  if (typeof value !== 'object' || value === null || Array.isArray(value))
@@ -136,15 +136,16 @@ function sectionOf(defaults, provider) {
136
136
  }
137
137
  /**
138
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.
139
+ * index.ts calls it at apply time so every later synchronous read sees the
140
+ * persisted state; store tests also call it after resetting the singleton.
141
141
  */
142
142
  export async function loadModelDefaults() {
143
143
  await ensureReady();
144
144
  }
145
145
  /**
146
- * The last load failure, or a warning about entries that were skipped while
147
- * loading; consumers only use it for diagnostics.
146
+ * Test-only observation of the last load failure or skipped-entry warning.
147
+ * No production diagnostic consumer currently reads it.
148
+ * @internal Exported for tests only.
148
149
  */
149
150
  export function modelDefaultsLoadError() {
150
151
  return loadError;
@@ -164,7 +165,10 @@ export function defaultEffortOf(provider, model) {
164
165
  // breaks that model's resolution).
165
166
  return Object.prototype.hasOwnProperty.call(section, model) ? section[model] : undefined;
166
167
  }
167
- /** A detached snapshot for the RPC surface (render + diffing). */
168
+ /**
169
+ * Detached snapshot for store assertions in tests; no production RPC uses it.
170
+ * @internal Exported for tests only.
171
+ */
168
172
  export function modelDefaultsSnapshot() {
169
173
  const result = {};
170
174
  for (const provider of PROVIDER_IDS) {
@@ -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
  }
@@ -60,8 +60,6 @@ export declare function isAntigravityPermanentRefreshError(error: unknown): bool
60
60
  export declare function fetchAntigravityModels(session: AntigravitySession, runtime?: AntigravityRuntimeConfig, fetchFn?: FetchFn, signal?: AbortSignal): Promise<DiscoveredModel[]>;
61
61
  /** Fetch plan and per-model quota windows when the upstream exposes them. */
62
62
  export declare function fetchAntigravityUsage(session: AntigravitySession, runtime?: AntigravityRuntimeConfig, fetchFn?: FetchFn, signal?: AbortSignal): Promise<ProviderUsage>;
63
- /** URL for either v1internal generation transport. */
64
- export declare function antigravityGenerateURL(baseURL: string | undefined, stream: boolean): string;
65
63
  /** Forward one already-built payload to generateContent or streamGenerateContent. */
66
64
  export declare function requestAntigravityContent(session: AntigravitySession, payload: AntigravityRequest, stream: boolean, runtime?: AntigravityRuntimeConfig, fetchFn?: FetchFn, signal?: AbortSignal): Promise<Response>;
67
65
  export interface AntigravityAdapterOptions {
@@ -7,7 +7,7 @@ import { errorChain, EMPTY_RESPONSE_CODE, LlmAdapter, LlmError } from '@deepseek
7
7
  import { resolveImages } from '../translate/resolved.js';
8
8
  import { antigravityReasoning } from '../translate/antigravity-thinking.js';
9
9
  import { parseAntigravityResponse, streamAntigravity, toAntigravityRequest, } from '../translate/antigravity.js';
10
- import { httpLlmError, idleWatchdog, mapFetchFailure, mergeReasoning, ModelCatalogCache, discoverOrRetryAuth, isMissingOrInvalidCredential, oauthEndpointError, OAuthEndpointError, discoverAcrossAccounts, isDiscoveryAborted, } from './common.js';
10
+ import { httpLlmError, idleWatchdog, mapFetchFailure, mergeReasoning, ModelCatalogCache, discoverOrRetryAuth, isMissingOrInvalidCredential, parseProviderJson, oauthEndpointError, OAuthEndpointError, discoverAcrossAccounts, isDiscoveryAborted, } from './common.js';
11
11
  import { hostFetch } from '../http.js';
12
12
  import { ANTIGRAVITY_DEFAULT_CLIENT_ID, ANTIGRAVITY_DEFAULT_CLIENT_SECRET } from './antigravity-oauth-client.js';
13
13
  import { AccountTokenManager, DISCOVERY_TIMEOUT_MS, unionAccountCatalogs } from './accounts.js';
@@ -126,7 +126,7 @@ async function callInternal(method, body, accessToken, runtime, fetchFn, signal)
126
126
  }, runtime, fetchFn);
127
127
  if (!response.ok)
128
128
  throw await httpLlmError(response, `Antigravity ${method}`);
129
- return response.json();
129
+ return parseProviderJson(response, `Antigravity ${method}`);
130
130
  }
131
131
  function projectIdOf(value) {
132
132
  if (typeof value === 'string' && value.length > 0)
@@ -217,7 +217,7 @@ export async function exchangeAntigravityCode(code, verifier, redirectUri, oauth
217
217
  });
218
218
  if (!response.ok)
219
219
  throw await oauthEndpointError(response, 'Antigravity');
220
- const tokens = await response.json();
220
+ const tokens = await parseProviderJson(response, 'Antigravity token exchange');
221
221
  if (typeof tokens.access_token !== 'string')
222
222
  throw new Error('Antigravity token endpoint returned no access token');
223
223
  const account = await discoverAntigravityAccount(tokens.access_token, runtime, fetchFn);
@@ -238,7 +238,7 @@ export async function refreshAntigravity(session, oauth, fetchFn = hostFetch) {
238
238
  });
239
239
  if (!response.ok)
240
240
  throw await oauthEndpointError(response, 'Antigravity');
241
- return sessionFromTokens(await response.json(), {
241
+ return sessionFromTokens(await parseProviderJson(response, 'Antigravity token refresh'), {
242
242
  projectId: session.projectId,
243
243
  ...session.account === undefined ? {} : { account: session.account },
244
244
  ...session.plan === undefined ? {} : { plan: session.plan },
@@ -313,10 +313,6 @@ export async function fetchAntigravityUsage(session, runtime = {}, fetchFn = hos
313
313
  ...displayPlan === undefined ? {} : { plan: displayPlan },
314
314
  };
315
315
  }
316
- /** URL for either v1internal generation transport. */
317
- export function antigravityGenerateURL(baseURL, stream) {
318
- return `${antigravityBaseURL(baseURL)}/v1internal:${stream ? 'streamGenerateContent?alt=sse' : 'generateContent'}`;
319
- }
320
316
  /** Forward one already-built payload to generateContent or streamGenerateContent. */
321
317
  export async function requestAntigravityContent(session, payload, stream, runtime = {}, fetchFn = hostFetch, signal) {
322
318
  return fetchAntigravity(stream ? 'streamGenerateContent?alt=sse' : 'generateContent', {
@@ -511,6 +507,6 @@ export class AntigravityAdapter extends LlmAdapter {
511
507
  const response = await requestAntigravityContent(session, toAntigravityRequest(options, messages, session.projectId), false, this.options.runtime, this.options.fetchFn, options.signal);
512
508
  if (!response.ok)
513
509
  throw await httpLlmError(response, 'Antigravity API');
514
- return parseAntigravityResponse(await response.json());
510
+ return parseAntigravityResponse(await parseProviderJson(response, 'Antigravity generateContent'));
515
511
  }
516
512
  }
@@ -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,9 +5,9 @@
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
- import { httpLlmError, idleWatchdog, mapFetchFailure, mergeReasoning, ModelCatalogCache, discoverAcrossAccounts, discoverOrRetryAuth, isDiscoveryAborted, isMissingOrInvalidCredential, oauthEndpointError, OAuthEndpointError, } from './common.js';
10
+ import { httpLlmError, idleWatchdog, mapFetchFailure, mergeReasoning, ModelCatalogCache, discoverAcrossAccounts, discoverOrRetryAuth, isDiscoveryAborted, isMissingOrInvalidCredential, parseProviderJson, oauthEndpointError, OAuthEndpointError, } from './common.js';
11
11
  import { AccountTokenManager, DISCOVERY_TIMEOUT_MS, unionAccountCatalogs } from './accounts.js';
12
12
  import { hostFetch } from '../http.js';
13
13
  import { compareVersions } from './npm-cli-version.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));
@@ -212,7 +212,7 @@ export async function exchangeClaudeCode(code, verifier, redirectUri, state) {
212
212
  });
213
213
  if (!response.ok)
214
214
  throw await oauthEndpointError(response, 'claude');
215
- return claudeSession(await response.json(), undefined, true);
215
+ return claudeSession(await parseProviderJson(response, 'claude token exchange'), undefined, true);
216
216
  }
217
217
  /**
218
218
  * Refresh a claude session (JSON grant echoing the issued scope).
@@ -232,7 +232,7 @@ export async function refreshClaude(session) {
232
232
  });
233
233
  if (!response.ok)
234
234
  throw await oauthEndpointError(response, 'claude');
235
- const next = await claudeSession(await response.json(), session.refreshToken, false);
235
+ const next = await claudeSession(await parseProviderJson(response, 'claude token refresh'), session.refreshToken, false);
236
236
  return {
237
237
  ...next,
238
238
  ...session.emailAddress === undefined ? {} : { emailAddress: session.emailAddress },
@@ -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
- const payload = await response.json();
401
+ const payload = await parseProviderJson(response, 'claude usage');
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;
@@ -388,7 +462,7 @@ export async function fetchClaudeModels(session, fetchFn = hostFetch, signal, cl
388
462
  });
389
463
  if (!response.ok)
390
464
  throw await httpLlmError(response, 'claude models API');
391
- const payload = await response.json();
465
+ const payload = await parseProviderJson(response, 'claude models API');
392
466
  if (!Array.isArray(payload.data)) {
393
467
  throw new Error('claude models API returned an invalid catalog');
394
468
  }
@@ -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
+ // The supported 0.1.7-rc.2 floor reads `offloadImages`; older option types lack 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',