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
@@ -10,6 +10,7 @@
10
10
  import { EMPTY_RESPONSE_CODE, LlmAdapter, LlmError, QUOTA_EXCEEDED_CODE, } from '@deepseek-ai/dsh-llm';
11
11
  import { poolKey } from './pool-family.js';
12
12
  import { accountKey, classifyPoolFailure, memberKey, PoolHealthRegistry } from './pool-health.js';
13
+ import { streamAccountWithReplay } from './replay.js';
13
14
  /** Bound on sticky-session memory; oldest entries evict past it. */
14
15
  const STICKY_SESSION_LIMIT = 1000;
15
16
  /** Display form of one member (account shown when pinned). */
@@ -200,6 +201,12 @@ export class PoolAdapter extends LlmAdapter {
200
201
  ...modalities === undefined ? {} : { inputModalities: modalities },
201
202
  };
202
203
  }
204
+ /**
205
+ * Fail over before output. A single non-quota failure keeps its identity
206
+ * and shared provider message, with scoped pool recovery or its own hint as fallback.
207
+ * Multiple failures carry a bounded summary and all attempts in an AggregateError cause.
208
+ * Pure quota/rate exhaustion retains RATE_LIMIT and this pool's earliest recovery hint.
209
+ */
203
210
  async *stream(options) {
204
211
  const definition = (await this.pools()).get(poolKey(options.provider, options.model));
205
212
  if (definition === undefined)
@@ -208,12 +215,12 @@ export class PoolAdapter extends LlmAdapter {
208
215
  const candidates = await this.select(options.model, members, options.sessionId);
209
216
  if (candidates.length === 0)
210
217
  throw this.exhausted(options.model, members);
211
- let lastError;
218
+ const failures = [];
212
219
  for (const member of candidates) {
213
220
  const adapter = this.options.adapters[member.provider];
214
221
  if (adapter === undefined)
215
222
  continue;
216
- const iterator = adapter.streamAccount({ ...options, provider: member.provider, model: member.model }, member.account)[Symbol.asyncIterator]();
223
+ const iterator = streamAccountWithReplay(adapter, { ...options, provider: member.provider }, member.account, member.model)[Symbol.asyncIterator]();
217
224
  let first;
218
225
  try {
219
226
  first = await iterator.next();
@@ -223,7 +230,7 @@ export class PoolAdapter extends LlmAdapter {
223
230
  }
224
231
  catch (error) {
225
232
  const classification = classifyPoolFailure(error, member.provider);
226
- if (classification.action === 'throw')
233
+ if (classification.action === 'throw' || !(error instanceof LlmError))
227
234
  throw error;
228
235
  if ('cooldownMs' in classification) {
229
236
  this.options.health.markUnavailable(classification.scope === 'account'
@@ -238,7 +245,7 @@ export class PoolAdapter extends LlmAdapter {
238
245
  }
239
246
  this.options.onWarn(`pool "${options.model}": ${memberLabel(member)} failed before any output`
240
247
  + ` (${error instanceof Error ? error.message : String(error)}); trying the next member`);
241
- lastError = error;
248
+ failures.push(error);
242
249
  continue;
243
250
  }
244
251
  this.remember(options.model, options.sessionId, member);
@@ -264,7 +271,7 @@ export class PoolAdapter extends LlmAdapter {
264
271
  }
265
272
  return;
266
273
  }
267
- throw this.exhausted(options.model, members, lastError);
274
+ throw this.exhausted(options.model, members, failures);
268
275
  }
269
276
  /**
270
277
  * Order the candidates for one request. Health filters both strategies;
@@ -316,12 +323,11 @@ export class PoolAdapter extends LlmAdapter {
316
323
  }
317
324
  this.sticky.set(key, memberKey(member.provider, member.account, member.model));
318
325
  }
319
- /**
320
- * The error for an exhausted pool, carrying the earliest recovery hint of
321
- * THIS pool's members (the health registry is shared across pools, so the
322
- * hint is scoped to the keys this pool can actually recover through).
323
- */
324
- exhausted(model, pool, cause) {
326
+ /** Report attempt failures or this pool's active cooldowns, never unrelated health records. */
327
+ exhausted(model, pool, failures = []) {
328
+ if (pool.length === 0) {
329
+ return new LlmError(`pool "${model}" has no usable member: no account could be resolved`, 'NO_ADAPTER');
330
+ }
325
331
  const keys = new Set();
326
332
  for (const member of pool) {
327
333
  keys.add(memberKey(member.provider, member.account, member.model));
@@ -329,12 +335,83 @@ export class PoolAdapter extends LlmAdapter {
329
335
  }
330
336
  const recovery = this.options.health.earliestRecovery(keys);
331
337
  const retryAfterMs = recovery === undefined ? undefined : Math.max(recovery - Date.now(), 1);
332
- return new LlmError(`pool "${model}" exhausted: every member is unavailable or failed`, 'RATE_LIMIT', {
338
+ const coolingReasons = this.options.health.unavailableReasons(keys);
339
+ const codes = [...failures.map(error => error.code), ...coolingReasons];
340
+ if (codes.length === 0)
341
+ return new LlmError(`pool "${model}" has no usable member`, 'NO_ADAPTER');
342
+ const quotaOnly = codes.every(isQuotaFailure);
343
+ if (failures.length === 1 && !isQuotaFailure(failures[0].code)) {
344
+ const failure = failures[0];
345
+ // Keep the same error, code, status and cause. The message arrives already
346
+ // free of provider text: the shared converter in common.ts owns that, and
347
+ // non-pooled callers receive the same text, so the pool adds no sanitizer
348
+ // of its own and cannot make the two paths disagree.
349
+ // The pool's earliest scoped recovery wins even if the member's hint is
350
+ // later: another member may recover sooner. Without scoped recovery,
351
+ // keep the member hint; with neither, leave it absent.
352
+ // Keep the error object itself. LlmError freezes its facts snapshot, so
353
+ // replace that snapshot to correct only providerRetryAfterMs for pool
354
+ // routing; this is not permission to rewrite other frozen failure facts.
355
+ if (retryAfterMs !== undefined && retryAfterMs !== failure.failure.providerRetryAfterMs) {
356
+ Object.defineProperty(failure, 'failure', {
357
+ value: Object.freeze({ ...failure.failure, providerRetryAfterMs: retryAfterMs }),
358
+ });
359
+ }
360
+ return failure;
361
+ }
362
+ const attemptedCodes = failures.map(error => error.code);
363
+ const details = [...attemptedCodes, ...coolingReasons.filter(code => !attemptedCodes.includes(code))];
364
+ const summary = failureSummary(details);
365
+ const cause = failures.length === 0
366
+ ? undefined
367
+ : failures.length === 1
368
+ ? failures[0]
369
+ : new AggregateError(failures, `${failures.length} pool members failed: ${summary}`);
370
+ // Subscription quotas reopen: preserve the existing retry classification and text.
371
+ const message = quotaOnly
372
+ ? `pool "${model}" exhausted: every member is unavailable or failed`
373
+ : failures.length > 0
374
+ ? `pool "${model}" exhausted: ${failures.length} ${failures.length === 1 ? 'member' : 'members'} failed (${summary})`
375
+ : `pool "${model}" unavailable: ${pool.length} members are cooling down (${summary})`;
376
+ // For mixed failures, a real non-quota failure must not become a rate limit
377
+ // merely because the last member hit its quota. No new aggregate error code.
378
+ const code = quotaOnly
379
+ ? 'RATE_LIMIT'
380
+ : attemptedCodes.filter(code => !isQuotaFailure(code)).at(-1)
381
+ ?? coolingReasons.filter(code => !isQuotaFailure(code)).at(-1);
382
+ return new LlmError(message, code, {
333
383
  ...retryAfterMs === undefined ? {} : { providerRetryAfterMs: retryAfterMs },
334
384
  ...cause === undefined ? {} : { cause },
335
385
  });
336
386
  }
337
387
  }
388
+ function isQuotaFailure(code) {
389
+ return code === QUOTA_EXCEEDED_CODE || code === 'RATE_LIMIT';
390
+ }
391
+ /** Summaries use stable codes only: adapter messages and nested causes can contain raw payloads. */
392
+ function failureSummary(codes) {
393
+ const descriptions = {
394
+ AUTH: 'authentication failed',
395
+ INVALID_CREDENTIAL: 'credential is invalid',
396
+ MISSING_CREDENTIAL: 'credential is missing',
397
+ SERVER: 'provider server failed',
398
+ TIMEOUT: 'provider request timed out',
399
+ EMPTY_RESPONSE: 'provider returned no output',
400
+ TRANSPORT: 'provider connection failed',
401
+ HTTP_402: 'provider plan unavailable',
402
+ HTTP_404: 'provider model unavailable',
403
+ QUOTA: 'provider quota exhausted',
404
+ RATE_LIMIT: 'provider rate limit reached',
405
+ };
406
+ const limit = 5;
407
+ const shown = codes.slice(0, limit).map(code => {
408
+ const description = descriptions[code];
409
+ return description === undefined ? 'member unavailable' : `${code}: ${description}`;
410
+ });
411
+ if (codes.length > limit)
412
+ shown.push(`${codes.length - limit} more`);
413
+ return shown.join('; ');
414
+ }
338
415
  function stickyKey(poolId, sessionId) {
339
416
  return `${String(sessionId)}|${poolId}`;
340
417
  }
@@ -110,20 +110,6 @@ export declare function earliestReset(...candidates: (number | undefined)[]): nu
110
110
  * @returns the wait in milliseconds, never below {@link MIN_WAIT_MS}.
111
111
  */
112
112
  export declare function waitFromReset(instant: number, now: number): number;
113
- /**
114
- * Render the rate-limit-shaped headers and the head of the body of a 429 whose
115
- * reset instant nothing parsed. Emitted through the adapter's `onWarn`, this is
116
- * how an unrecognized provider field gets named from live traffic instead of
117
- * being guessed at.
118
- *
119
- * It is also where the per-bucket rollover snapshots land by design — no reader
120
- * parks a turn on one, because on a 429 they cannot say which bucket refused —
121
- * so the operator still sees what the provider disclosed.
122
- * @param response - the failed response.
123
- * @param body - the complete response body.
124
- * @returns a one-line diagnostic.
125
- */
126
- export declare function rateLimitDiagnostics(response: Response, body: string): string;
127
113
  /** Per-route retry shape a subscription adapter starts from. */
128
114
  export interface RetryDefaults {
129
115
  /** Retries after the first attempt. */
@@ -238,32 +238,6 @@ export function earliestReset(...candidates) {
238
238
  export function waitFromReset(instant, now) {
239
239
  return Math.max(MIN_WAIT_MS, instant - now + RESET_GRACE_MS);
240
240
  }
241
- /** Header names worth showing when a 429 disclosed no reset this code recognizes. */
242
- const DIAGNOSTIC_HEADER = /rate-?limit|retry|reset|^x-codex-/i;
243
- /**
244
- * Render the rate-limit-shaped headers and the head of the body of a 429 whose
245
- * reset instant nothing parsed. Emitted through the adapter's `onWarn`, this is
246
- * how an unrecognized provider field gets named from live traffic instead of
247
- * being guessed at.
248
- *
249
- * It is also where the per-bucket rollover snapshots land by design — no reader
250
- * parks a turn on one, because on a 429 they cannot say which bucket refused —
251
- * so the operator still sees what the provider disclosed.
252
- * @param response - the failed response.
253
- * @param body - the complete response body.
254
- * @returns a one-line diagnostic.
255
- */
256
- export function rateLimitDiagnostics(response, body) {
257
- const headers = [];
258
- response.headers.forEach((value, key) => {
259
- if (DIAGNOSTIC_HEADER.test(key))
260
- headers.push(`${key}: ${value}`);
261
- });
262
- headers.sort();
263
- const rendered = headers.length > 0 ? headers.join('; ') : '(none)';
264
- const head = body.slice(0, 200);
265
- return `429 disclosed no reset time; headers [${rendered}]; body ${head.length > 0 ? head : '(empty)'}`;
266
- }
267
241
  /**
268
242
  * The retry shape every subscription route starts from: Claude Code's own SDK
269
243
  * numbers — ten retries after the first attempt, exponential backoff from 1s
@@ -0,0 +1,4 @@
1
+ import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm';
2
+ import type { AccountAwareAdapter } from './accounts.js';
3
+ /** Bind opaque replay to the concrete account/model behind a route alias. */
4
+ export declare function streamAccountWithReplay(adapter: AccountAwareAdapter, options: GenerateOptions, account: string, model?: string): AsyncIterable<StreamChunk>;
@@ -0,0 +1,50 @@
1
+ import { createHash } from 'node:crypto';
2
+ const REPLAY_KIND = 'subscriptions-account-replay';
3
+ /** Bind opaque replay to the concrete account/model behind a route alias. */
4
+ export async function* streamAccountWithReplay(adapter, options, account, model = options.model) {
5
+ const key = createHash('sha256')
6
+ .update(JSON.stringify([options.provider, account, model])).digest('hex');
7
+ const messages = options.messages.map(message => {
8
+ if (message.role !== 'assistant'
9
+ || message.source?.replayState === undefined)
10
+ return message;
11
+ const source = message.source;
12
+ if (source.replayState === null || typeof source.replayState !== 'object'
13
+ || !('response' in source.replayState))
14
+ return message;
15
+ const replay = source.replayState;
16
+ const response = replay.response;
17
+ if (response !== null && typeof response === 'object'
18
+ && 'kind' in response && response.kind === REPLAY_KIND) {
19
+ if ('version' in response && response.version === 1
20
+ && 'key' in response && response.key === key && 'state' in response) {
21
+ return { ...message, source: {
22
+ ...source, provider: options.provider, model,
23
+ replayState: { ...replay, response: response.state },
24
+ } };
25
+ }
26
+ const { replayState: _discarded, ...withoutReplay } = source;
27
+ return { ...message, source: withoutReplay };
28
+ }
29
+ // Legacy replay has no originating-account identity. Preserve direct
30
+ // histories when provider/model match, as upstream does; switching
31
+ // accounts mid-session can therefore reuse the previous account's signed
32
+ // request prefix. An alias cannot identify the legacy concrete member.
33
+ if (source.provider === options.provider && source.model === model) {
34
+ return message;
35
+ }
36
+ const { replayState: _discarded, ...withoutReplay } = source;
37
+ return { ...message, source: withoutReplay };
38
+ });
39
+ for await (const chunk of adapter.streamAccount({
40
+ ...options, model, messages,
41
+ }, account)) {
42
+ if (chunk.type === 'finish' && chunk.replayState !== undefined) {
43
+ yield { ...chunk, replayState: { ...chunk.replayState, response: {
44
+ kind: REPLAY_KIND, version: 1, key, state: chunk.replayState.response,
45
+ } } };
46
+ }
47
+ else
48
+ yield chunk;
49
+ }
50
+ }
@@ -0,0 +1,51 @@
1
+ import type { ProviderUsage } from './common.js';
2
+ export interface ResetConfirmation {
3
+ ticket: string;
4
+ expiresAt: number;
5
+ creditExpiresAt?: number;
6
+ weeklyUsedPercent: number;
7
+ }
8
+ /**
9
+ * Manual-only redemption with single-use tickets. An ambiguous submission parks
10
+ * the account to prevent an immediate blind retry. This block is process-local,
11
+ * not a durable rate limit: restarting the host clears it. Re-check credits and
12
+ * limits in Codex before retrying; a lost success response may have spent a
13
+ * credit already, and another submission can spend another credit.
14
+ * Rationale: [manual reset note](../../.agents/notes/implemented/provider/2026-10-04-codex-manual-reset.md).
15
+ */
16
+ export declare class ResetRedemption {
17
+ private readonly read;
18
+ private readonly consume;
19
+ private readonly invalidate;
20
+ /**
21
+ * Stable identity for one real account: the same key the usage cache uses,
22
+ * so the guards and the cache invalidate together. The RPC carries whatever
23
+ * reference the browser holds — a canonical id, a legacy key, an email, a
24
+ * workspace id — and all of them resolve to one stored account.
25
+ *
26
+ * It must not fall back to the raw reference on failure. A fallback would
27
+ * re-key the state by whatever happened to arrive, so a transient lookup
28
+ * error could let a later alias past an uncertain park. Resolution here
29
+ * reads the local credential store only, so failing is safe and honest.
30
+ */
31
+ private readonly canonical;
32
+ private tickets;
33
+ private busy;
34
+ private uncertain;
35
+ constructor(read: (account: string, signal: AbortSignal) => Promise<ProviderUsage>, consume: (account: string, creditId: string, requestId: string, signal: AbortSignal) => Promise<void>, invalidate: (account: string) => void,
36
+ /**
37
+ * Stable identity for one real account: the same key the usage cache uses,
38
+ * so the guards and the cache invalidate together. The RPC carries whatever
39
+ * reference the browser holds — a canonical id, a legacy key, an email, a
40
+ * workspace id — and all of them resolve to one stored account.
41
+ *
42
+ * It must not fall back to the raw reference on failure. A fallback would
43
+ * re-key the state by whatever happened to arrive, so a transient lookup
44
+ * error could let a later alias past an uncertain park. Resolution here
45
+ * reads the local credential store only, so failing is safe and honest.
46
+ */
47
+ canonical: (account: string, signal: AbortSignal) => Promise<string>);
48
+ private eligible;
49
+ prepare(account: string, signal: AbortSignal): Promise<ResetConfirmation>;
50
+ redeem(account: string, ticket: string, signal: AbortSignal): Promise<void>;
51
+ }
@@ -0,0 +1,102 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ /**
3
+ * Manual-only redemption with single-use tickets. An ambiguous submission parks
4
+ * the account to prevent an immediate blind retry. This block is process-local,
5
+ * not a durable rate limit: restarting the host clears it. Re-check credits and
6
+ * limits in Codex before retrying; a lost success response may have spent a
7
+ * credit already, and another submission can spend another credit.
8
+ * Rationale: [manual reset note](../../.agents/notes/implemented/provider/2026-10-04-codex-manual-reset.md).
9
+ */
10
+ export class ResetRedemption {
11
+ read;
12
+ consume;
13
+ invalidate;
14
+ canonical;
15
+ tickets = new Map();
16
+ busy = new Set();
17
+ uncertain = new Set();
18
+ constructor(read, consume, invalidate,
19
+ /**
20
+ * Stable identity for one real account: the same key the usage cache uses,
21
+ * so the guards and the cache invalidate together. The RPC carries whatever
22
+ * reference the browser holds — a canonical id, a legacy key, an email, a
23
+ * workspace id — and all of them resolve to one stored account.
24
+ *
25
+ * It must not fall back to the raw reference on failure. A fallback would
26
+ * re-key the state by whatever happened to arrive, so a transient lookup
27
+ * error could let a later alias past an uncertain park. Resolution here
28
+ * reads the local credential store only, so failing is safe and honest.
29
+ */
30
+ canonical) {
31
+ this.read = read;
32
+ this.consume = consume;
33
+ this.invalidate = invalidate;
34
+ this.canonical = canonical;
35
+ }
36
+ eligible(usage) {
37
+ const weekly = usage.windows?.find(w => w.kind === 'weekly' && w.scope === undefined);
38
+ if (!weekly || !Number.isFinite(weekly.usedPercent) || weekly.usedPercent < 80
39
+ || weekly.resetsAt === undefined || weekly.resetsAt <= Date.now()) {
40
+ throw new Error('Reset blocked: fresh weekly usage must be at least 80%.');
41
+ }
42
+ if (usage.resetCreditsError)
43
+ throw new Error('Reset credits could not be verified.');
44
+ return weekly.usedPercent;
45
+ }
46
+ async prepare(account, signal) {
47
+ const who = await this.canonical(account, signal);
48
+ if (this.busy.has(who) || this.uncertain.has(who))
49
+ throw new Error('Reset blocked: an operation is pending or its outcome is uncertain. Check Codex before retrying.');
50
+ const usage = await this.read(account, signal);
51
+ // Re-check after the read. A confirmation prepared while another client was
52
+ // redeeming would otherwise be minted against a park that has since been
53
+ // cleared, letting the next click spend a second credit.
54
+ if (this.busy.has(who) || this.uncertain.has(who))
55
+ throw new Error('Reset blocked: an operation is pending or its outcome is uncertain. Check Codex before retrying.');
56
+ const weeklyUsedPercent = this.eligible(usage);
57
+ const credit = usage.resetCredits?.filter(c => c.id && (c.expiresAt === undefined || c.expiresAt > Date.now()))
58
+ .sort((a, b) => (a.expiresAt ?? Infinity) - (b.expiresAt ?? Infinity))[0];
59
+ if (!credit?.id)
60
+ throw new Error('No available reset credit.');
61
+ for (const [key, value] of this.tickets)
62
+ if (value.account === who || value.expiresAt <= Date.now())
63
+ this.tickets.delete(key);
64
+ const ticket = randomUUID();
65
+ const expiresAt = Date.now() + 60_000;
66
+ this.tickets.set(ticket, { account: who, creditId: credit.id, expiresAt });
67
+ return { ticket, expiresAt, weeklyUsedPercent, ...(credit.expiresAt === undefined ? {} : { creditExpiresAt: credit.expiresAt }) };
68
+ }
69
+ async redeem(account, ticket, signal) {
70
+ const who = await this.canonical(account, signal);
71
+ const pending = this.tickets.get(ticket);
72
+ this.tickets.delete(ticket);
73
+ if (!pending || pending.account !== who || pending.expiresAt <= Date.now())
74
+ throw new Error('Confirmation expired. Start again.');
75
+ if (this.busy.has(who) || this.uncertain.has(who))
76
+ throw new Error('Reset blocked: operation pending or uncertain.');
77
+ this.busy.add(who);
78
+ try {
79
+ const usage = await this.read(account, signal);
80
+ // The ticket bounds how long the confirmation counts. A read that
81
+ // outlives it must not go on to submit, so this comes first.
82
+ if (pending.expiresAt <= Date.now())
83
+ throw new Error('Confirmation expired. Start again.');
84
+ this.eligible(usage);
85
+ if (!usage.resetCredits?.some(c => c.id === pending.creditId && (c.expiresAt === undefined || c.expiresAt > Date.now())))
86
+ throw new Error('The selected reset is no longer available.');
87
+ // Mark before submission: no blind retry after transport or response ambiguity.
88
+ this.uncertain.add(who);
89
+ try {
90
+ await this.consume(account, pending.creditId, randomUUID(), signal);
91
+ }
92
+ catch {
93
+ throw new Error('Reset outcome could not be confirmed. Do not retry or restart to bypass this block; verify credits and limits in Codex first.');
94
+ }
95
+ this.uncertain.delete(who);
96
+ }
97
+ finally {
98
+ this.invalidate(who);
99
+ this.busy.delete(who);
100
+ }
101
+ }
102
+ }
@@ -21,6 +21,7 @@ import { AccountTokenManager } from '../providers/accounts.js';
21
21
  import { ImageAccountPool } from '../providers/image-pool.js';
22
22
  import { codexRateLimitReset } from '../providers/codex.js';
23
23
  import { grokRateLimitReset } from '../providers/grok.js';
24
+ import { parseProviderJson } from '../providers/common.js';
24
25
  import { hostFetch } from '../http.js';
25
26
  /** Endpoint the codex generation request is posted to. */
26
27
  export const IMAGE_GENERATE_URL = 'https://chatgpt.com/backend-api/codex/images/generations';
@@ -387,7 +388,7 @@ export function createImageGenerateTool(options) {
387
388
  await manager.session(); // logged out: throws the provider's log-in hint
388
389
  throw new Error('image_generate: no image provider is logged in');
389
390
  }
390
- const images = parseImageGenerateResponse(await response.json());
391
+ const images = parseImageGenerateResponse(await parseProviderJson(response, 'image generate'));
391
392
  const directory = options.imagesDir ?? imagesDirectory();
392
393
  await mkdir(directory, { recursive: true });
393
394
  const paths = [];
@@ -57,10 +57,6 @@ export declare function buildVideoGenerateBody(args: {
57
57
  resolution?: '480p' | '720p' | '1080p';
58
58
  image_url?: string;
59
59
  }): VideoGenerateRequestBody;
60
- /**
61
- * Extract the request id from the submit response. Throws when the payload
62
- * carries none.
63
- */
64
60
  export declare function parseVideoStartResponse(payload: unknown): string;
65
61
  /** One decoded poll response. */
66
62
  export type VideoStatus = {
@@ -71,7 +67,6 @@ export type VideoStatus = {
71
67
  duration?: number;
72
68
  } | {
73
69
  status: 'failed' | 'expired';
74
- detail?: string;
75
70
  };
76
71
  /**
77
72
  * Decode one poll response. A `done` payload without a video URL and an
@@ -12,7 +12,7 @@ import { mkdir, writeFile } from 'node:fs/promises';
12
12
  import { basename, join } from 'node:path';
13
13
  import { dshHomePath } from '@deepseek-ai/dsh-home-paths';
14
14
  import { defineTool } from '@deepseek-ai/dsh-tools';
15
- import { httpLlmError } from '../providers/common.js';
15
+ import { httpLlmError, parseProviderJson } from '../providers/common.js';
16
16
  import { AccountTokenManager } from '../providers/accounts.js';
17
17
  import { hostFetch } from '../http.js';
18
18
  /** Endpoint the generation request is posted to. */
@@ -60,10 +60,20 @@ function isRecord(value) {
60
60
  * Extract the request id from the submit response. Throws when the payload
61
61
  * carries none.
62
62
  */
63
+ /**
64
+ * A provider-assigned request id, accepted only in a bounded identifier shape.
65
+ * The value reaches a user-visible failure message, so an unchecked one would
66
+ * let a provider put arbitrary text there. The shape rules out prose, spaces
67
+ * and message excerpts; it does NOT prove the value is harmless, because a
68
+ * credential-shaped string such as `SHORT_SECRET` matches it. Treat this as a
69
+ * bounded, format-constrained provider value that is displayed for support
70
+ * correlation — not as the absence of provider-supplied content.
71
+ */
72
+ const REQUEST_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
63
73
  export function parseVideoStartResponse(payload) {
64
74
  const body = isRecord(payload) ? payload : {};
65
- if (typeof body.request_id !== 'string' || body.request_id.length === 0) {
66
- throw new Error('video_generate: the response carried no request_id');
75
+ if (typeof body.request_id !== 'string' || !REQUEST_ID_PATTERN.test(body.request_id)) {
76
+ throw new Error('video_generate: the response carried no usable request_id');
67
77
  }
68
78
  return body.request_id;
69
79
  }
@@ -89,15 +99,13 @@ export function parseVideoStatusResponse(payload) {
89
99
  };
90
100
  }
91
101
  case 'failed':
92
- case 'expired': {
93
- const error = isRecord(body.error) ? body.error : {};
94
- const detail = typeof error.message === 'string' && error.message.length > 0
95
- ? error.message
96
- : typeof body.error === 'string' && body.error.length > 0 ? body.error : undefined;
97
- return { status: body.status, ...detail === undefined ? {} : { detail } };
98
- }
102
+ case 'expired':
103
+ // Safe means this failure path carries no provider-controlled free text,
104
+ // not that no secret can exist elsewhere in the system.
105
+ return { status: body.status };
99
106
  default:
100
- throw new Error(`video_generate: unexpected status ${JSON.stringify(body.status)}`);
107
+ // Safe here also excludes provider-controlled free text, not all system secrets.
108
+ throw new Error('video_generate: unexpected status: [provider response body omitted]');
101
109
  }
102
110
  }
103
111
  /** Directory the downloaded MP4 files are written to. */
@@ -212,7 +220,7 @@ export function createVideoGenerateTool(options) {
212
220
  });
213
221
  if (!submit.ok)
214
222
  throw await httpLlmError(submit, 'video_generate');
215
- const requestId = parseVideoStartResponse(await submit.json());
223
+ const requestId = parseVideoStartResponse(await parseProviderJson(submit, 'video generate start'));
216
224
  const deadline = Date.now() + maxWaitMs;
217
225
  let done;
218
226
  for (;;) {
@@ -224,14 +232,17 @@ export function createVideoGenerateTool(options) {
224
232
  });
225
233
  if (!poll.ok)
226
234
  throw await httpLlmError(poll, 'video_generate');
227
- const status = parseVideoStatusResponse(await poll.json());
235
+ const status = parseVideoStatusResponse(await parseProviderJson(poll, 'video generate status poll'));
228
236
  if (status.status === 'done') {
229
237
  done = status;
230
238
  break;
231
239
  }
232
240
  if (status.status === 'failed' || status.status === 'expired') {
241
+ // No provider response text on this path. The request id is shaped like an
242
+ // opaque id before it is stored (see REQUEST_ID_PATTERN), so it is an
243
+ // identifier rather than provider prose; anything else was refused at parse.
233
244
  throw new Error(`video_generate: generation ${status.status} (request ${requestId})`
234
- + (status.detail === undefined ? '' : `: ${status.detail}`));
245
+ + ': [provider response body omitted]');
235
246
  }
236
247
  if (Date.now() >= deadline) {
237
248
  throw new Error(`video_generate: timed out after ${String(maxWaitMs)}ms waiting for request ${requestId}`);
@@ -5,7 +5,7 @@
5
5
  * output is `{ answer, citations }`.
6
6
  */
7
7
  import { defineTool } from '@deepseek-ai/dsh-tools';
8
- import { httpLlmError } from '../providers/common.js';
8
+ import { httpLlmError, parseProviderJson } from '../providers/common.js';
9
9
  import { AccountTokenManager } from '../providers/accounts.js';
10
10
  import { hostFetch } from '../http.js';
11
11
  /** Endpoint the search request is posted to. */
@@ -191,7 +191,7 @@ export function createXSearchTool(options) {
191
191
  });
192
192
  if (!response.ok)
193
193
  throw await httpLlmError(response, 'x_search');
194
- return parseXSearchResponse(await response.json());
194
+ return parseXSearchResponse(await parseProviderJson(response, 'x search'));
195
195
  },
196
196
  });
197
197
  }
@@ -32,6 +32,23 @@ export declare const CACHE_BLOCK_STRIDE = 15;
32
32
  * blocks before a read is lost.
33
33
  */
34
34
  export declare const MESSAGE_CACHE_BREAKPOINTS = 3;
35
+ /**
36
+ * How long Anthropic keeps a cache entry after its last use: five minutes
37
+ * (the API default) or one hour. A one-hour entry is written at twice the base
38
+ * input price instead of 1.25×, and reads cost the same either way.
39
+ */
40
+ export type PromptCacheTtl = '5m' | '1h';
41
+ /** The API default; a marker at this TTL carries no `ttl` field at all. */
42
+ export declare const DEFAULT_PROMPT_CACHE_TTL: PromptCacheTtl;
43
+ /**
44
+ * Build one `cache_control` marker.
45
+ *
46
+ * Five minutes is spelled by omitting `ttl`, so a request that does not opt in
47
+ * stays byte-identical to what earlier releases sent.
48
+ * @param ttl - cache lifetime; defaults to five minutes.
49
+ * @returns a fresh marker object (callers attach it to one block each).
50
+ */
51
+ export declare function cacheControl(ttl?: PromptCacheTtl): Record<string, unknown>;
35
52
  /** One Anthropic request message. */
36
53
  export interface AnthropicMessage {
37
54
  role: 'user' | 'assistant';
@@ -62,17 +79,19 @@ export declare function toAnthropicMessages(messages: readonly TranslatableMessa
62
79
  * Marks are counted across the flattened block sequence, not per message,
63
80
  * because the lookback window Anthropic walks counts blocks the same way.
64
81
  * @param messages - assembled Anthropic messages, marked in place.
82
+ * @param ttl - cache lifetime for every mark; defaults to five minutes.
65
83
  */
66
- export declare function markMessageCache(messages: readonly AnthropicMessage[]): void;
84
+ export declare function markMessageCache(messages: readonly AnthropicMessage[], ttl?: PromptCacheTtl): void;
67
85
  /**
68
86
  * Build the Anthropic `system` array: the mandatory Claude Code identity
69
87
  * block, then the explicit system prompt, then any system-role messages.
70
88
  * @param system - explicit system prompt, when set.
71
89
  * @param messages - conversation messages; the system-role text preceding the
72
90
  * conversation is appended, and a later one is left to {@link toAnthropicMessages}.
91
+ * @param ttl - cache lifetime for the tools+system mark; defaults to five minutes.
73
92
  * @returns the system content blocks.
74
93
  */
75
- export declare function toAnthropicSystem(system?: string, messages?: readonly TranslatableMessage[]): Record<string, unknown>[];
94
+ export declare function toAnthropicSystem(system?: string, messages?: readonly TranslatableMessage[], ttl?: PromptCacheTtl): Record<string, unknown>[];
76
95
  /**
77
96
  * Map harness tool schemas to Anthropic tools, in name order.
78
97
  *