dsh-subscriptions 0.1.3 → 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 (58) hide show
  1. package/README.md +64 -0
  2. package/lib/auth/rpc.d.ts +3 -1
  3. package/lib/auth/rpc.js +10 -1
  4. package/lib/client/ProviderAccountManager.js +34 -41
  5. package/lib/client/ProviderModelEditor.js +5 -9
  6. package/lib/client/SpeedSelect.js +8 -7
  7. package/lib/client/SubscriptionUsageBadge.js +6 -5
  8. package/lib/client/SubscriptionsSection.d.ts +22 -0
  9. package/lib/client/SubscriptionsSection.js +128 -38
  10. package/lib/client/UsageBadgeDisplaySetting.js +4 -8
  11. package/lib/client/host-contract.d.ts +1 -1
  12. package/lib/client/host-contract.js +22 -0
  13. package/lib/client/locales.d.ts +58 -0
  14. package/lib/client/locales.js +58 -0
  15. package/lib/client/provider-settings-styles.d.ts +3 -0
  16. package/lib/client/provider-settings-styles.js +206 -0
  17. package/lib/client/reset-action.d.ts +14 -0
  18. package/lib/client/reset-action.js +15 -0
  19. package/lib/client/reset-credits-view.d.ts +53 -0
  20. package/lib/client/reset-credits-view.js +103 -0
  21. package/lib/client.js +937 -310
  22. package/lib/client.js.map +1 -1
  23. package/lib/index.d.ts +9 -0
  24. package/lib/index.js +1099 -608
  25. package/lib/providers/account-preferences.js +3 -2
  26. package/lib/providers/catalog-store.d.ts +3 -2
  27. package/lib/providers/catalog-store.js +57 -26
  28. package/lib/providers/claude.d.ts +22 -3
  29. package/lib/providers/claude.js +113 -15
  30. package/lib/providers/codex.d.ts +15 -2
  31. package/lib/providers/codex.js +72 -4
  32. package/lib/providers/common.d.ts +25 -0
  33. package/lib/providers/common.js +22 -18
  34. package/lib/providers/copilot.d.ts +2 -1
  35. package/lib/providers/copilot.js +10 -3
  36. package/lib/providers/grok.d.ts +2 -2
  37. package/lib/providers/grok.js +2 -2
  38. package/lib/providers/pool-health.d.ts +2 -0
  39. package/lib/providers/pool-health.js +9 -0
  40. package/lib/providers/pool-usage.d.ts +13 -1
  41. package/lib/providers/pool-usage.js +39 -7
  42. package/lib/providers/pool.d.ts +7 -5
  43. package/lib/providers/pool.js +89 -12
  44. package/lib/providers/rate-limit.d.ts +0 -14
  45. package/lib/providers/rate-limit.js +0 -26
  46. package/lib/providers/replay.d.ts +4 -0
  47. package/lib/providers/replay.js +50 -0
  48. package/lib/providers/reset-redemption.d.ts +51 -0
  49. package/lib/providers/reset-redemption.js +102 -0
  50. package/lib/translate/anthropic.d.ts +21 -2
  51. package/lib/translate/anthropic.js +35 -11
  52. package/lib/translate/antigravity.js +13 -6
  53. package/lib/translate/chat-completions.js +1 -1
  54. package/lib/translate/resolved.d.ts +21 -0
  55. package/lib/translate/resolved.js +63 -3
  56. package/lib/translate/responses.js +8 -6
  57. package/lib/translate/sse.js +8 -0
  58. package/package.json +1 -1
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { createHash, randomUUID } from 'node:crypto';
9
9
  import { CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmError, QUOTA_EXCEEDED_CODE, ReasoningEffortId, } from '@deepseek-ai/dsh-llm';
10
- import { durationMs, rateLimitDiagnostics, retryAfterInstant, waitFromReset } from './rate-limit.js';
10
+ import { durationMs, retryAfterInstant, waitFromReset } from './rate-limit.js';
11
11
  /**
12
12
  * Validate a configured model catalog (mirrors llm-deepseek's resolveModels).
13
13
  * @param models - raw configured entries.
@@ -50,6 +50,17 @@ export function validateModels(models, label) {
50
50
  };
51
51
  });
52
52
  }
53
+ /**
54
+ * Provider failures can echo credentials or customer input in any format.
55
+ * Shape-based filtering cannot make arbitrary response text safe to display.
56
+ * Here, safe means this diagnostic carries no provider-controlled free text,
57
+ * not that secrets cannot exist elsewhere in the system. Keep raw responses
58
+ * internal for classification and reset parsing; display only local summaries.
59
+ * This deliberately loses model, parameter and plan details useful to support.
60
+ */
61
+ function providerDiagnostic(summary, hasBody) {
62
+ return hasBody ? `${summary}: [provider response body omitted]` : summary;
63
+ }
53
64
  /**
54
65
  * Build an LlmError from a non-2xx provider response, mapping the status to a
55
66
  * stable code and, for a rate-limited request, the disclosed reset instant to
@@ -73,11 +84,8 @@ export async function httpLlmError(response, label, options = {}) {
73
84
  catch {
74
85
  // Only swallow error-body reading: the HTTP status still identifies the failure.
75
86
  }
76
- // Truncated for display only; the readers below need the whole body to parse it.
87
+ // Preserve the existing classification window; reset readers need the full body.
77
88
  const shown = body.slice(0, 500);
78
- const message = shown.length > 0
79
- ? `${label} error (HTTP ${String(response.status)}): ${shown}`
80
- : `${label} error (HTTP ${String(response.status)})`;
81
89
  let code;
82
90
  if (response.status === 401 || response.status === 403)
83
91
  code = 'AUTH';
@@ -93,6 +101,7 @@ export async function httpLlmError(response, label, options = {}) {
93
101
  code = 'SERVER';
94
102
  else
95
103
  code = `HTTP_${String(response.status)}`;
104
+ const message = providerDiagnostic(`${label} error (HTTP ${String(response.status)}, ${code})`, body.length > 0);
96
105
  const now = Date.now();
97
106
  // The provider's reader runs on a 429 and nowhere else. Providers attach
98
107
  // their rate-limit headers to every response, so reading them on a transient
@@ -111,7 +120,8 @@ export async function httpLlmError(response, label, options = {}) {
111
120
  ? options.rateLimitReset?.(response, body, now) ?? googleQuotaReset(body, now) ?? retryAfterInstant(response, now)
112
121
  : retryAfterInstant(response, now);
113
122
  if (reset === undefined && rateLimited) {
114
- options.onWarn?.(`${label}: ${rateLimitDiagnostics(response, body)}`);
123
+ // Header names and values are provider-controlled too; none are displayed.
124
+ options.onWarn?.(providerDiagnostic(`${label}: 429 disclosed no reset time; [provider response headers omitted]`, body.length > 0));
115
125
  }
116
126
  return new LlmError(message, code, {
117
127
  status: response.status,
@@ -246,9 +256,10 @@ export class OAuthEndpointError extends Error {
246
256
  */
247
257
  export async function oauthEndpointError(response, label) {
248
258
  let oauthCode;
249
- let detail = '';
259
+ let body = '';
250
260
  try {
251
- const parsed = await response.json();
261
+ body = await response.text();
262
+ const parsed = JSON.parse(body);
252
263
  if (typeof parsed.error === 'string') {
253
264
  oauthCode = parsed.error;
254
265
  }
@@ -256,20 +267,13 @@ export async function oauthEndpointError(response, label) {
256
267
  const nested = parsed.error;
257
268
  if (typeof nested.code === 'string' && nested.code.length > 0)
258
269
  oauthCode = nested.code;
259
- if (typeof nested.message === 'string')
260
- detail = nested.message;
261
270
  }
262
- if (typeof parsed.error_description === 'string')
263
- detail = parsed.error_description;
264
- if (detail.length === 0)
265
- detail = oauthCode ?? '';
266
271
  }
267
272
  catch {
268
- // Only swallow error-body parsing: the HTTP status still identifies the failure.
273
+ // Only swallow error-body reading/parsing: HTTP status still identifies the failure.
269
274
  }
270
- const message = detail.length > 0
271
- ? `${label} token endpoint error (HTTP ${String(response.status)}): ${detail}`
272
- : `${label} token endpoint error (HTTP ${String(response.status)})`;
275
+ // oauthCode remains structural for auth classification, never display text.
276
+ const message = providerDiagnostic(`${label} token endpoint error (HTTP ${String(response.status)})`, body.length > 0);
273
277
  return new OAuthEndpointError(message, response.status, oauthCode, parseRetryAfterMs(response));
274
278
  }
275
279
  /**
@@ -297,7 +297,8 @@ export declare class CopilotAdapter extends LlmAdapter {
297
297
  * account identity is the session's long-lived GitHub token (stable across
298
298
  * Copilot-token refreshes, different per GitHub login); the conversation is
299
299
  * the loop-stamped `sessionId`, falling back to the first message's id
300
- * when a hand-built request carries no session stamp; the model separates
300
+ * when a hand-built request carries no session stamp. Identity-free
301
+ * requests have no replay scope; the model separates
301
302
  * wire families. A call id captured in one scope is invisible to every
302
303
  * other scope, so reused ids cannot leak reasoning across accounts,
303
304
  * conversations, or models.
@@ -677,7 +677,8 @@ export class CopilotAdapter extends LlmAdapter {
677
677
  * account identity is the session's long-lived GitHub token (stable across
678
678
  * Copilot-token refreshes, different per GitHub login); the conversation is
679
679
  * the loop-stamped `sessionId`, falling back to the first message's id
680
- * when a hand-built request carries no session stamp; the model separates
680
+ * when a hand-built request carries no session stamp. Identity-free
681
+ * requests have no replay scope; the model separates
681
682
  * wire families. A call id captured in one scope is invisible to every
682
683
  * other scope, so reused ids cannot leak reasoning across accounts,
683
684
  * conversations, or models.
@@ -685,9 +686,11 @@ export class CopilotAdapter extends LlmAdapter {
685
686
  replayScope(tokenKey, options) {
686
687
  const conversation = options.sessionId !== undefined
687
688
  ? `session:${String(options.sessionId)}`
688
- : options.messages[0] !== undefined
689
+ : options.messages[0]?.id !== undefined
689
690
  ? `anchor:${String(options.messages[0].id)}`
690
- : 'conversation:none';
691
+ : undefined;
692
+ if (conversation === undefined)
693
+ return undefined;
691
694
  return `${tokenKey}\u0000${conversation}\u0000${options.model}`;
692
695
  }
693
696
  /**
@@ -701,6 +704,8 @@ export class CopilotAdapter extends LlmAdapter {
701
704
  * per call.
702
705
  */
703
706
  captureReasoning(scope, callIds, items) {
707
+ if (scope === undefined)
708
+ return;
704
709
  let entries = this.replayByScope.get(scope);
705
710
  if (entries === undefined) {
706
711
  entries = new Map();
@@ -745,6 +750,8 @@ export class CopilotAdapter extends LlmAdapter {
745
750
  * degradation, never an error.
746
751
  */
747
752
  replayFor(scope, callId) {
753
+ if (scope === undefined)
754
+ return undefined;
748
755
  const entries = this.replayByScope.get(scope);
749
756
  const entry = entries?.get(callId);
750
757
  if (entries === undefined || entry === undefined)
@@ -25,8 +25,8 @@ export declare const GROK_PREEMPT_MS: number;
25
25
  * whose values are rollover durations (`6m0s`) present on every response, one
26
26
  * per bucket — on a 429 the earliest of them is usually a bucket with room
27
27
  * (`0s` for the request bucket while the token bucket is the one exhausted),
28
- * which would burn the whole retry budget in seconds. They reach the operator
29
- * through `rateLimitDiagnostics` instead.
28
+ * which would burn the whole retry budget in seconds. The parsed body reset
29
+ * supplies the retry hint; diagnostics omit raw provider headers and body text.
30
30
  */
31
31
  export declare const grokRateLimitReset: RateLimitResetReader;
32
32
  /** Discovered OIDC endpoints for the xAI authorization server. */
@@ -29,8 +29,8 @@ const GROK_RESET_FIELDS = ['retry_after', 'retry_after_seconds', 'resets_at', 'r
29
29
  * whose values are rollover durations (`6m0s`) present on every response, one
30
30
  * per bucket — on a 429 the earliest of them is usually a bucket with room
31
31
  * (`0s` for the request bucket while the token bucket is the one exhausted),
32
- * which would burn the whole retry budget in seconds. They reach the operator
33
- * through `rateLimitDiagnostics` instead.
32
+ * which would burn the whole retry budget in seconds. The parsed body reset
33
+ * supplies the retry hint; diagnostics omit raw provider headers and body text.
34
34
  */
35
35
  export const grokRateLimitReset = (_response, body, now) => resetFromFields(jsonBody(body), GROK_RESET_FIELDS, now);
36
36
  /** A discovered URL must be https on x.ai or a subdomain; anything else is a hostile document. */
@@ -69,6 +69,8 @@ export declare class PoolHealthRegistry {
69
69
  * `providerRetryAfterMs`.
70
70
  */
71
71
  earliestRecovery(keys: ReadonlySet<string>, now?: number): number | undefined;
72
+ /** Active failure codes for the given keys; reading them does not change cooldowns. */
73
+ unavailableReasons(keys: ReadonlySet<string>, now?: number): string[];
72
74
  /** Drop records of one provider, or of a single account when given (auth changes). */
73
75
  clear(provider: ProviderId, account?: string): void;
74
76
  }
@@ -139,6 +139,15 @@ export class PoolHealthRegistry {
139
139
  }
140
140
  return earliest;
141
141
  }
142
+ /** Active failure codes for the given keys; reading them does not change cooldowns. */
143
+ unavailableReasons(keys, now = Date.now()) {
144
+ const reasons = [];
145
+ for (const [key, record] of this.records) {
146
+ if (keys.has(key) && record.unavailableUntil > now)
147
+ reasons.push(record.reason);
148
+ }
149
+ return reasons;
150
+ }
142
151
  /** Drop records of one provider, or of a single account when given (auth changes). */
143
152
  clear(provider, account) {
144
153
  const prefix = account === undefined ? `${provider}/` : `${provider}/${account}/`;
@@ -39,6 +39,11 @@ export declare class PoolUsageTracker {
39
39
  private readonly ttlMs;
40
40
  private readonly entries;
41
41
  private readonly inflight;
42
+ /** Bumped by {@link invalidate} so a fetch started before it cannot repopulate the cache. */
43
+ private readonly generation;
44
+ /** Monotonic per-key write order: only the newest settled fetch may cache. */
45
+ private nextSequence;
46
+ private readonly lastWritten;
42
47
  constructor(fetcherFor: (provider: ProviderId, account: string) => (() => Promise<ProviderUsage>) | undefined, ttlMs?: number);
43
48
  /**
44
49
  * The quota view of one member. A cold cache awaits the first fetch; a
@@ -70,7 +75,14 @@ export declare class PoolUsageTracker {
70
75
  * @returns `{ supported: false }` when the provider has no usage fetcher.
71
76
  */
72
77
  snapshotFor(provider: ProviderId, account: string, force?: boolean): Promise<ProviderUsage>;
73
- /** Drop cached snapshots: one account, or a whole provider when `account` is omitted. */
78
+ /**
79
+ * Drop cached snapshots: one account, or a whole provider when `account` is
80
+ * omitted. A fetch already in flight is abandoned too. It was started against
81
+ * state the caller has just declared changed — a redeemed credit, a completed
82
+ * login — so joining it would return the pre-change numbers and cache them for
83
+ * the full freshness window. Its result is discarded on arrival by the
84
+ * generation check in {@link refresh}.
85
+ */
74
86
  invalidate(provider: ProviderId, account?: string): void;
75
87
  /**
76
88
  * Run (or join) the single in-flight fetch for one account key, caching
@@ -34,6 +34,11 @@ export class PoolUsageTracker {
34
34
  ttlMs;
35
35
  entries = new Map();
36
36
  inflight = new Map();
37
+ /** Bumped by {@link invalidate} so a fetch started before it cannot repopulate the cache. */
38
+ generation = new Map();
39
+ /** Monotonic per-key write order: only the newest settled fetch may cache. */
40
+ nextSequence = 0;
41
+ lastWritten = new Map();
37
42
  constructor(fetcherFor, ttlMs = USAGE_TTL_MS) {
38
43
  this.fetcherFor = fetcherFor;
39
44
  this.ttlMs = ttlMs;
@@ -126,15 +131,27 @@ export class PoolUsageTracker {
126
131
  throw error;
127
132
  }
128
133
  }
129
- /** Drop cached snapshots: one account, or a whole provider when `account` is omitted. */
134
+ /**
135
+ * Drop cached snapshots: one account, or a whole provider when `account` is
136
+ * omitted. A fetch already in flight is abandoned too. It was started against
137
+ * state the caller has just declared changed — a redeemed credit, a completed
138
+ * login — so joining it would return the pre-change numbers and cache them for
139
+ * the full freshness window. Its result is discarded on arrival by the
140
+ * generation check in {@link refresh}.
141
+ */
130
142
  invalidate(provider, account) {
143
+ const drop = (key) => {
144
+ this.entries.delete(key);
145
+ this.inflight.delete(key);
146
+ this.generation.set(key, (this.generation.get(key) ?? 0) + 1);
147
+ };
131
148
  if (account !== undefined) {
132
- this.entries.delete(`${provider}/${account}`);
149
+ drop(`${provider}/${account}`);
133
150
  return;
134
151
  }
135
- for (const key of [...this.entries.keys()]) {
152
+ for (const key of [...this.entries.keys(), ...this.inflight.keys()]) {
136
153
  if (key.startsWith(`${provider}/`))
137
- this.entries.delete(key);
154
+ drop(key);
138
155
  }
139
156
  }
140
157
  /**
@@ -155,13 +172,26 @@ export class PoolUsageTracker {
155
172
  // consecutive failure, even though nothing newer ever replaced it.
156
173
  const prior = this.entries.get(key);
157
174
  const lastSnapshot = prior?.snapshot ?? prior?.lastSnapshot;
175
+ const generation = this.generation.get(key) ?? 0;
176
+ // A newer fetch may settle before this one does. The generation only
177
+ // catches an invalidate; the sequence catches a plain race, where this
178
+ // fetch fails late and would otherwise cache a cooldown over a fresher
179
+ // success that already landed.
180
+ const sequence = ++this.nextSequence;
158
181
  pending = fetcher().then((value) => {
159
182
  const at = Date.now();
160
183
  const snapshot = { ...value, observedAt: at, stale: false };
161
- this.entries.set(key, { snapshot, at });
184
+ // An invalidate() during the fetch means this result describes a world
185
+ // that has moved on; caching it would resurrect the old numbers.
186
+ if ((this.generation.get(key) ?? 0) === generation && (this.lastWritten.get(key) ?? 0) < sequence) {
187
+ this.lastWritten.set(key, sequence);
188
+ this.entries.set(key, { snapshot, at });
189
+ }
162
190
  return snapshot;
163
191
  }, (error) => {
164
- if (!isMissingOrInvalidCredential(error)) {
192
+ if (!isMissingOrInvalidCredential(error) && (this.generation.get(key) ?? 0) === generation
193
+ && (this.lastWritten.get(key) ?? 0) < sequence) {
194
+ this.lastWritten.set(key, sequence);
165
195
  this.entries.set(key, {
166
196
  error,
167
197
  at: Date.now(),
@@ -171,7 +201,9 @@ export class PoolUsageTracker {
171
201
  }
172
202
  throw error;
173
203
  }).finally(() => {
174
- this.inflight.delete(key);
204
+ // Only clear our own slot: an invalidate may have replaced it.
205
+ if (this.inflight.get(key) === pending)
206
+ this.inflight.delete(key);
175
207
  });
176
208
  this.inflight.set(key, pending);
177
209
  }
@@ -87,6 +87,12 @@ export declare class PoolAdapter extends LlmAdapter {
87
87
  * metadata is account-specific, so every distinct member is resolved.
88
88
  */
89
89
  resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo>;
90
+ /**
91
+ * Fail over before output. A single non-quota failure keeps its identity
92
+ * and shared provider message, with scoped pool recovery or its own hint as fallback.
93
+ * Multiple failures carry a bounded summary and all attempts in an AggregateError cause.
94
+ * Pure quota/rate exhaustion retains RATE_LIMIT and this pool's earliest recovery hint.
95
+ */
90
96
  stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
91
97
  /**
92
98
  * Order the candidates for one request. Health filters both strategies;
@@ -99,10 +105,6 @@ export declare class PoolAdapter extends LlmAdapter {
99
105
  private select;
100
106
  /** Pin the serving member to the session (with bounded memory). */
101
107
  private remember;
102
- /**
103
- * The error for an exhausted pool, carrying the earliest recovery hint of
104
- * THIS pool's members (the health registry is shared across pools, so the
105
- * hint is scoped to the keys this pool can actually recover through).
106
- */
108
+ /** Report attempt failures or this pool's active cooldowns, never unrelated health records. */
107
109
  private exhausted;
108
110
  }
@@ -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
+ }