dsh-plugin-subscriptions 0.5.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +45 -6
  2. package/README.zh.md +43 -4
  3. package/lib/auth/rpc.d.ts +36 -2
  4. package/lib/auth/rpc.js +47 -5
  5. package/lib/client/ImageGenerateToolview.d.ts +1 -1
  6. package/lib/client/SpeedSelect.d.ts +25 -2
  7. package/lib/client/SpeedSelect.js +10 -6
  8. package/lib/client/SubscriptionsSection.d.ts +74 -0
  9. package/lib/client/SubscriptionsSection.js +325 -4
  10. package/lib/client/VideoGenerateToolview.d.ts +1 -1
  11. package/lib/client/index.d.ts +1 -9
  12. package/lib/client/index.js +7 -4
  13. package/lib/client/locales.d.ts +28 -0
  14. package/lib/client/locales.js +28 -0
  15. package/lib/client.js +458 -10
  16. package/lib/client.js.map +1 -1
  17. package/lib/compat.d.ts +36 -0
  18. package/lib/compat.js +20 -0
  19. package/lib/index.d.ts +5 -1
  20. package/lib/index.js +865 -111
  21. package/lib/model-defaults.d.ts +23 -0
  22. package/lib/model-defaults.js +237 -0
  23. package/lib/providers/claude.d.ts +24 -3
  24. package/lib/providers/claude.js +35 -24
  25. package/lib/providers/codex.d.ts +21 -0
  26. package/lib/providers/codex.js +37 -10
  27. package/lib/providers/common.d.ts +70 -6
  28. package/lib/providers/common.js +118 -19
  29. package/lib/providers/copilot.d.ts +10 -0
  30. package/lib/providers/copilot.js +21 -8
  31. package/lib/providers/grok.d.ts +21 -0
  32. package/lib/providers/grok.js +37 -7
  33. package/lib/providers/pool-usage.d.ts +23 -2
  34. package/lib/providers/pool-usage.js +70 -15
  35. package/lib/providers/rate-limit.d.ts +192 -0
  36. package/lib/providers/rate-limit.js +338 -0
  37. package/lib/translate/anthropic.js +5 -4
  38. package/lib/translate/chat-completions.js +5 -4
  39. package/lib/translate/responses.js +5 -4
  40. package/package.json +21 -21
  41. package/lib/providers/antigravity.d.ts +0 -90
  42. package/lib/providers/antigravity.js +0 -392
  43. package/lib/translate/antigravity.d.ts +0 -110
  44. package/lib/translate/antigravity.js +0 -303
@@ -10,7 +10,7 @@
10
10
  * `quota_aware` strategy therefore prefers the highest-urgency member, which
11
11
  * over time converges on every window hitting zero right at its reset.
12
12
  */
13
- import { isMissingOrInvalidCredential } from './common.js';
13
+ import { isMissingOrInvalidCredential, OAuthEndpointError } from './common.js';
14
14
  /** A member is taken out of rotation once any window crosses this fill level. */
15
15
  export const QUOTA_FULL_PERCENT = 95;
16
16
  /** How long a usage snapshot is trusted before a background refresh. */
@@ -41,7 +41,8 @@ export class PoolUsageTracker {
41
41
  /**
42
42
  * The quota view of one member. A cold cache awaits the first fetch; a
43
43
  * stale one answers immediately while the refresh serves the NEXT call
44
- * (member selection must never block on the network mid-conversation).
44
+ * (member selection must never block on the network mid-conversation). A
45
+ * failure still cooling down degrades immediately with no network call.
45
46
  * @param member - the pool member to score (account resolved).
46
47
  * @returns availability plus the urgency score.
47
48
  */
@@ -51,26 +52,53 @@ export class PoolUsageTracker {
51
52
  if (fetcher === undefined)
52
53
  return { available: true, urgency: 0, fetchedAt: 0 };
53
54
  const entry = this.entries.get(key);
54
- if (entry !== undefined && Date.now() - entry.at < this.ttlMs) {
55
- return this.score(member, entry);
56
- }
57
55
  if (entry !== undefined) {
58
- void this.refresh(key, fetcher).catch(() => undefined);
59
- return this.score(member, entry);
56
+ const fresh = Date.now() - entry.at < (entry.cooldownMs ?? this.ttlMs);
57
+ if (entry.snapshot !== undefined) {
58
+ if (!fresh)
59
+ void this.refresh(key, fetcher).catch(() => undefined);
60
+ return this.score(member, entry);
61
+ }
62
+ if (fresh)
63
+ return degradedQuota(entry.error);
64
+ // The cooldown expired: fall through to a fresh, blocking attempt.
60
65
  }
61
66
  try {
62
67
  const snapshot = await this.refresh(key, fetcher);
63
68
  return this.score(member, { snapshot, at: Date.now() });
64
69
  }
65
70
  catch (error) {
66
- // Logged out: the member cannot serve at all. Any other failure
67
- // (network, endpoint rate limit) must not block routing — the member
68
- // stays available with a zero score, degrading the strategy to plain
69
- // priority order for it.
70
- return isMissingOrInvalidCredential(error)
71
- ? { available: false, urgency: 0, fetchedAt: 0 }
72
- : { available: true, urgency: 0, fetchedAt: 0 };
71
+ return degradedQuota(error);
72
+ }
73
+ }
74
+ /**
75
+ * Same cache as {@link quotaFor}, for direct display (the Settings page):
76
+ * the raw snapshot, or the original fetch error, instead of a routing
77
+ * score.
78
+ * @param provider - the account's provider.
79
+ * @param account - the account key.
80
+ * @param force - bypass a fresh cached SNAPSHOT for an honest re-check (the
81
+ * manual Refresh button). A live failure cooldown is never bypassed —
82
+ * retrying through it is exactly what turns a 429 into a permanent
83
+ * lockout, so even a forced call still answers from the negative cache.
84
+ * @returns `{ supported: false }` when the provider has no usage fetcher.
85
+ */
86
+ async snapshotFor(provider, account, force = false) {
87
+ const fetcher = this.fetcherFor(provider, account);
88
+ if (fetcher === undefined)
89
+ return { supported: false };
90
+ const key = `${provider}/${account}`;
91
+ const entry = this.entries.get(key);
92
+ if (entry !== undefined && Date.now() - entry.at < (entry.cooldownMs ?? this.ttlMs)) {
93
+ if (entry.snapshot !== undefined) {
94
+ if (!force)
95
+ return entry.snapshot;
96
+ }
97
+ else {
98
+ throw entry.error;
99
+ }
73
100
  }
101
+ return this.refresh(key, fetcher);
74
102
  }
75
103
  /** Drop cached snapshots: one account, or a whole provider when `account` is omitted. */
76
104
  invalidate(provider, account) {
@@ -83,13 +111,25 @@ export class PoolUsageTracker {
83
111
  this.entries.delete(key);
84
112
  }
85
113
  }
86
- /** Run (or join) the single in-flight fetch for one account key. */
114
+ /**
115
+ * Run (or join) the single in-flight fetch for one account key, caching
116
+ * either outcome. A missing/invalid credential is deliberately NOT
117
+ * negative-cached: it costs no network round trip (the session lookup
118
+ * fails before the request goes out) and re-checking live means the
119
+ * member rejoins routing the instant its login is fixed, rather than
120
+ * waiting out a stale cooldown.
121
+ */
87
122
  refresh(key, fetcher) {
88
123
  let pending = this.inflight.get(key);
89
124
  if (pending === undefined) {
90
125
  pending = fetcher().then((snapshot) => {
91
126
  this.entries.set(key, { snapshot, at: Date.now() });
92
127
  return snapshot;
128
+ }, (error) => {
129
+ if (!isMissingOrInvalidCredential(error)) {
130
+ this.entries.set(key, { error, at: Date.now(), cooldownMs: cooldownFor(error, this.ttlMs) });
131
+ }
132
+ throw error;
93
133
  }).finally(() => {
94
134
  this.inflight.delete(key);
95
135
  });
@@ -110,6 +150,21 @@ export class PoolUsageTracker {
110
150
  return { available, urgency, fetchedAt: entry.at };
111
151
  }
112
152
  }
153
+ /**
154
+ * The routing view of a fetch failure. Logged out: the member cannot serve
155
+ * at all. Any other failure (network, endpoint rate limit) must not block
156
+ * routing — the member stays available with a zero score, degrading the
157
+ * strategy to plain priority order for it.
158
+ */
159
+ function degradedQuota(error) {
160
+ return isMissingOrInvalidCredential(error)
161
+ ? { available: false, urgency: 0, fetchedAt: 0 }
162
+ : { available: true, urgency: 0, fetchedAt: 0 };
163
+ }
164
+ /** How long to hold a failure in the negative cache: the endpoint's own `retry-after`, or the default TTL. */
165
+ function cooldownFor(error, defaultTtlMs) {
166
+ return error instanceof OAuthEndpointError && error.retryAfterMs !== undefined ? error.retryAfterMs : defaultTtlMs;
167
+ }
113
168
  /**
114
169
  * Whether a window constrains this model: unscoped windows always do; a
115
170
  * model-scoped window (Claude's Opus/Sonnet lanes) applies when its scope
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Rate-limit window handling shared by the subscription adapters.
3
+ *
4
+ * A subscription plan is rate-limit shaped by design — a five-hour session
5
+ * window, a weekly window, and on some plans a per-model weekly one — so a 429
6
+ * is not a dead end: the window reopens at a time the provider discloses. This
7
+ * module turns that disclosure into the `providerRetryAfterMs` the optional
8
+ * `@deepseek-ai/dsh-llm-retry` plugin waits out, and resolves the retry policy
9
+ * whose `maxDelayMs` decides how long a route is allowed to hold the turn.
10
+ *
11
+ * The wait itself is provider-independent: adapters own the policy, the retry
12
+ * plugin executes it. Only the extraction of the reset instant differs, so each
13
+ * adapter contributes one {@link RateLimitResetReader} built from the parsing
14
+ * primitives here.
15
+ *
16
+ * @module dsh-plugin-subscriptions/providers/rate-limit
17
+ */
18
+ import type { ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm';
19
+ /**
20
+ * Reads the instant one provider's rate-limit window reopens off a 429.
21
+ * @param response - the failed response, for its headers.
22
+ * @param body - the complete response body (never truncated: readers parse JSON).
23
+ * @param now - the current epoch milliseconds, injected so parsing is testable.
24
+ * @returns epoch milliseconds of the reset, or undefined when the provider said nothing.
25
+ */
26
+ export type RateLimitResetReader = (response: Response, body: string, now: number) => number | undefined;
27
+ /** Default ceiling on a rate-limit wait: six hours covers a five-hour session window with slack. */
28
+ export declare const DEFAULT_RATE_LIMIT_MAX_WAIT_MS: number;
29
+ /**
30
+ * Interpret a bare numeric rate-limit value, which providers write in three
31
+ * shapes: epoch milliseconds, epoch seconds, or a delay in seconds. The
32
+ * magnitude separates them unambiguously for any plausible value — an epoch in
33
+ * seconds is ~1.8e9 today, while a delay of even a full week is ~6e5.
34
+ * @param value - the raw numeric value.
35
+ * @param now - the current epoch milliseconds.
36
+ * @returns epoch milliseconds of the reset, or undefined when the value is unusable.
37
+ */
38
+ export declare function resetInstantFromNumber(value: number, now: number): number | undefined;
39
+ /**
40
+ * Parse a Go-style duration (`6m0s`, `1h2m3.5s`, `150ms`) into milliseconds —
41
+ * the form OpenAI-compatible `x-ratelimit-reset-*` headers use.
42
+ * @param text - the raw header value.
43
+ * @returns the duration in milliseconds, or undefined when the text is not one.
44
+ */
45
+ export declare function durationMs(text: string): number | undefined;
46
+ /**
47
+ * Interpret any single rate-limit value — a number, a numeric string, a
48
+ * duration (`6m0s`), or a date — as the instant a window reopens. One reader
49
+ * for every shape, so a provider that changes the encoding of a field it
50
+ * already sends does not need a code change here.
51
+ * @param value - the raw header value or JSON field.
52
+ * @param now - the current epoch milliseconds.
53
+ * @returns epoch milliseconds of the reset, or undefined when the value is unusable.
54
+ */
55
+ export declare function resetInstantFromValue(value: unknown, now: number): number | undefined;
56
+ /**
57
+ * Read a header carrying any of the {@link resetInstantFromValue} shapes.
58
+ * @param response - the failed response.
59
+ * @param name - the header to read.
60
+ * @param now - the current epoch milliseconds.
61
+ * @returns epoch milliseconds of the reset, or undefined when absent or unusable.
62
+ */
63
+ export declare function resetInstantFromHeader(response: Response, name: string, now: number): number | undefined;
64
+ /**
65
+ * Read the RFC 7231 `retry-after` header in both its forms: a delay in seconds
66
+ * (never an epoch stamp, whatever its magnitude) or an HTTP-date.
67
+ * @param response - the failed response.
68
+ * @param now - the current epoch milliseconds.
69
+ * @returns epoch milliseconds of the reset, or undefined when absent or unusable.
70
+ */
71
+ export declare function retryAfterInstant(response: Response, now: number): number | undefined;
72
+ /**
73
+ * Parse a response body as JSON without throwing on the non-JSON bodies
74
+ * providers occasionally return under load (an HTML gateway page, say).
75
+ * @param body - the complete response body.
76
+ * @returns the parsed value, or undefined when the body is not JSON.
77
+ */
78
+ export declare function jsonBody(body: string): unknown;
79
+ /**
80
+ * Find a reset instant under any of the named keys, anywhere in a parsed body.
81
+ *
82
+ * The search is by key rather than by path on purpose: providers move the same
83
+ * field between containers (`detail`, `error`, top level) across endpoints and
84
+ * versions, and a path-shaped reader silently stops working when they do. Only
85
+ * the key list is provider-specific.
86
+ * @param value - the parsed body, or any nested value.
87
+ * @param keys - field names this provider uses for a reset or delay.
88
+ * @param now - the current epoch milliseconds.
89
+ * @param depth - remaining recursion depth.
90
+ * @returns the earliest instant found, or undefined when no key matched.
91
+ */
92
+ export declare function resetFromFields(value: unknown, keys: readonly string[], now: number, depth?: number): number | undefined;
93
+ /**
94
+ * The earliest of several candidate reset instants, ignoring absent ones. The
95
+ * earliest is the one that matters: it is the first moment any of the reported
96
+ * limits allows a request again.
97
+ * @param candidates - reset instants in no particular order.
98
+ * @returns the earliest instant, or undefined when every candidate is absent.
99
+ */
100
+ export declare function earliestReset(...candidates: (number | undefined)[]): number | undefined;
101
+ /**
102
+ * Turn a reset instant into the wait to report as `providerRetryAfterMs`.
103
+ *
104
+ * Deliberately not capped: a reset beyond the policy's `maxDelayMs` makes the
105
+ * retry plugin delegate immediately, failing the turn at once with the real
106
+ * reset in the message, rather than clamping the wait down and burning the
107
+ * retry budget against a window that is still closed.
108
+ * @param instant - epoch milliseconds the window reopens.
109
+ * @param now - the current epoch milliseconds.
110
+ * @returns the wait in milliseconds, never below {@link MIN_WAIT_MS}.
111
+ */
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
+ /** Per-route retry shape a subscription adapter starts from. */
128
+ export interface RetryDefaults {
129
+ /** Retries after the first attempt. */
130
+ readonly maxRetries: number;
131
+ /** First local backoff delay. */
132
+ readonly initialDelayMs: number;
133
+ /** Local backoff ceiling, and the accepted-provider-delay ceiling when waiting is off. */
134
+ readonly maxDelayMs: number;
135
+ /** Symmetric jitter around each local delay. */
136
+ readonly jitterRatio: number;
137
+ }
138
+ /**
139
+ * The retry shape every subscription route starts from: Claude Code's own SDK
140
+ * numbers — ten retries after the first attempt, exponential backoff from 1s
141
+ * doubling per attempt, capped at 60s, plus 20% jitter.
142
+ *
143
+ * Shared across all four routes rather than kept to claude, because what these
144
+ * numbers are tuned for is the shape of a subscription endpoint — a consumer
145
+ * plan behind a session window, which sheds load in bursts and rewards an
146
+ * attempt that outlasts them — and that is the same on all four. The dsh-llm
147
+ * defaults (5 retries from 500ms to 10s) give up after about fifteen seconds,
148
+ * which is short for that.
149
+ *
150
+ * The 60s cap governs local backoff only: a disclosed rate-limit reset is
151
+ * accepted up to the configured wait ceiling instead.
152
+ */
153
+ export declare const DEFAULT_RETRY: RetryDefaults;
154
+ /** How long a route may hold a turn open waiting for a rate-limit window. */
155
+ export interface RateLimitWait {
156
+ /** Whether a disclosed reset may be waited out at all. */
157
+ readonly wait: boolean;
158
+ /** Ceiling on one wait; a reset further out fails the turn instead. */
159
+ readonly maxWaitMs: number;
160
+ }
161
+ /** Rate-limit waiting as the plugin config accepts it. */
162
+ export interface RateLimitConfig {
163
+ /** Wait for a disclosed reset instead of failing the turn (default true). */
164
+ wait?: boolean;
165
+ /** Ceiling on one wait in milliseconds (default six hours). */
166
+ maxWaitMs?: number;
167
+ }
168
+ /** Waiting behavior a route falls back to when the plugin passed none (waiting on, six-hour ceiling). */
169
+ export declare const DEFAULT_RATE_LIMIT_WAIT: RateLimitWait;
170
+ /**
171
+ * Validate and default the rate-limit waiting config.
172
+ * @param config - the raw plugin config section, when present.
173
+ * @param path - diagnostic path naming the config that owns the value.
174
+ * @returns the resolved, immutable behavior.
175
+ */
176
+ export declare function resolveRateLimitWait(config: RateLimitConfig | undefined, path: string): RateLimitWait;
177
+ /**
178
+ * Resolve one route's retry policy, widening the delay ceiling to the
179
+ * configured wait so a disclosed reset hours out is accepted rather than
180
+ * refused.
181
+ *
182
+ * The ceiling is shared with local exponential backoff, so widening it also
183
+ * raises how long an unrelated transient failure may back off for. That stays
184
+ * bounded by the finite retry budget — the claude route's ten retries reach
185
+ * 512 s per attempt at most — and it only governs when the provider disclosed
186
+ * nothing, which is exactly the case where a longer wait is the safer guess.
187
+ * @param defaults - the route's retry shape.
188
+ * @param rateLimit - resolved waiting behavior.
189
+ * @param path - diagnostic path naming the provider route.
190
+ * @returns the policy to report from `providerRetryPolicy`.
191
+ */
192
+ export declare function subscriptionRetryPolicy(defaults: RetryDefaults, rateLimit: RateLimitWait, path: string): ResolvedRetryPolicy;
@@ -0,0 +1,338 @@
1
+ /**
2
+ * Rate-limit window handling shared by the subscription adapters.
3
+ *
4
+ * A subscription plan is rate-limit shaped by design — a five-hour session
5
+ * window, a weekly window, and on some plans a per-model weekly one — so a 429
6
+ * is not a dead end: the window reopens at a time the provider discloses. This
7
+ * module turns that disclosure into the `providerRetryAfterMs` the optional
8
+ * `@deepseek-ai/dsh-llm-retry` plugin waits out, and resolves the retry policy
9
+ * whose `maxDelayMs` decides how long a route is allowed to hold the turn.
10
+ *
11
+ * The wait itself is provider-independent: adapters own the policy, the retry
12
+ * plugin executes it. Only the extraction of the reset instant differs, so each
13
+ * adapter contributes one {@link RateLimitResetReader} built from the parsing
14
+ * primitives here.
15
+ *
16
+ * @module dsh-plugin-subscriptions/providers/rate-limit
17
+ */
18
+ import { resolveRetryPolicy } from '@deepseek-ai/dsh-llm';
19
+ /**
20
+ * Extra time added to every provider-disclosed wait. Absorbs clock skew
21
+ * between the harness and the provider, so a retry does not land a moment
22
+ * before the window actually reopens and burn an attempt on a second 429.
23
+ */
24
+ const RESET_GRACE_MS = 2_000;
25
+ /** Shortest wait ever scheduled, including for a reset instant already in the past. */
26
+ const MIN_WAIT_MS = 1_000;
27
+ /** Below this a bare number is a delay in seconds rather than an epoch stamp. */
28
+ const EPOCH_SECONDS_FLOOR = 1_000_000_000;
29
+ /** At or above this a bare epoch stamp is already in milliseconds. */
30
+ const EPOCH_MILLIS_FLOOR = 1_000_000_000_000;
31
+ /** Node's maximum timer delay; a longer wait cannot be scheduled at all. */
32
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
33
+ /** Default ceiling on a rate-limit wait: six hours covers a five-hour session window with slack. */
34
+ export const DEFAULT_RATE_LIMIT_MAX_WAIT_MS = 6 * 60 * 60 * 1_000;
35
+ /**
36
+ * Interpret a bare numeric rate-limit value, which providers write in three
37
+ * shapes: epoch milliseconds, epoch seconds, or a delay in seconds. The
38
+ * magnitude separates them unambiguously for any plausible value — an epoch in
39
+ * seconds is ~1.8e9 today, while a delay of even a full week is ~6e5.
40
+ * @param value - the raw numeric value.
41
+ * @param now - the current epoch milliseconds.
42
+ * @returns epoch milliseconds of the reset, or undefined when the value is unusable.
43
+ */
44
+ export function resetInstantFromNumber(value, now) {
45
+ if (!Number.isFinite(value) || value <= 0)
46
+ return undefined;
47
+ if (value >= EPOCH_MILLIS_FLOOR)
48
+ return value;
49
+ if (value >= EPOCH_SECONDS_FLOOR)
50
+ return value * 1_000;
51
+ // Provider body fields in this helper's allowlists are contracted as
52
+ // seconds. A provider that sends milliseconds here (for example,
53
+ // `retry_after: 30000`) would be interpreted as 30,000 seconds (~8.3 h),
54
+ // so such a field must be normalized by its provider reader first.
55
+ return now + value * 1_000;
56
+ }
57
+ /**
58
+ * Parse a Go-style duration (`6m0s`, `1h2m3.5s`, `150ms`) into milliseconds —
59
+ * the form OpenAI-compatible `x-ratelimit-reset-*` headers use.
60
+ * @param text - the raw header value.
61
+ * @returns the duration in milliseconds, or undefined when the text is not one.
62
+ */
63
+ export function durationMs(text) {
64
+ const trimmed = text.trim();
65
+ if (trimmed.length === 0)
66
+ return undefined;
67
+ // Sticky: every component must abut the previous one, so trailing or
68
+ // interleaved junk ("6m0s later") fails the length check below.
69
+ const pattern = /(\d+(?:\.\d+)?)(ms|h|m|s)/y;
70
+ const units = { h: 3_600_000, m: 60_000, s: 1_000, ms: 1 };
71
+ let total = 0;
72
+ let matched = false;
73
+ // A failed sticky exec resets `lastIndex` to zero, so the reached offset is
74
+ // tracked separately rather than read back off the regex after the loop.
75
+ let index = 0;
76
+ // Components run strictly coarse to fine, the only order Go writes them in;
77
+ // a repeated or out-of-order unit ("1s2h", "1s1s") is not a duration and
78
+ // must not be silently summed into one.
79
+ let previousUnit = Number.POSITIVE_INFINITY;
80
+ for (;;) {
81
+ pattern.lastIndex = index;
82
+ const match = pattern.exec(trimmed);
83
+ if (match === null)
84
+ break;
85
+ const unit = units[match[2]];
86
+ if (unit >= previousUnit)
87
+ return undefined;
88
+ previousUnit = unit;
89
+ total += Number(match[1]) * unit;
90
+ index = pattern.lastIndex;
91
+ matched = true;
92
+ }
93
+ if (!matched || index !== trimmed.length)
94
+ return undefined;
95
+ // A zero duration ("0s") is not a disclosed reset — it is a bucket that has
96
+ // already rolled over — and reporting it as one would short-circuit the real
97
+ // signal behind it with a wait of `now`. The numeric path agrees:
98
+ // {@link resetInstantFromNumber} rejects zero too.
99
+ return total > 0 ? total : undefined;
100
+ }
101
+ /**
102
+ * Interpret any single rate-limit value — a number, a numeric string, a
103
+ * duration (`6m0s`), or a date — as the instant a window reopens. One reader
104
+ * for every shape, so a provider that changes the encoding of a field it
105
+ * already sends does not need a code change here.
106
+ * @param value - the raw header value or JSON field.
107
+ * @param now - the current epoch milliseconds.
108
+ * @returns epoch milliseconds of the reset, or undefined when the value is unusable.
109
+ */
110
+ export function resetInstantFromValue(value, now) {
111
+ if (typeof value === 'number')
112
+ return resetInstantFromNumber(value, now);
113
+ if (typeof value !== 'string')
114
+ return undefined;
115
+ const trimmed = value.trim();
116
+ if (trimmed.length === 0)
117
+ return undefined;
118
+ const numeric = Number(trimmed);
119
+ if (Number.isFinite(numeric))
120
+ return resetInstantFromNumber(numeric, now);
121
+ const duration = durationMs(trimmed);
122
+ if (duration !== undefined)
123
+ return now + duration;
124
+ const parsed = Date.parse(trimmed);
125
+ return Number.isFinite(parsed) ? parsed : undefined;
126
+ }
127
+ /**
128
+ * Read a header carrying any of the {@link resetInstantFromValue} shapes.
129
+ * @param response - the failed response.
130
+ * @param name - the header to read.
131
+ * @param now - the current epoch milliseconds.
132
+ * @returns epoch milliseconds of the reset, or undefined when absent or unusable.
133
+ */
134
+ export function resetInstantFromHeader(response, name, now) {
135
+ return resetInstantFromValue(response.headers.get(name), now);
136
+ }
137
+ /**
138
+ * Read the RFC 7231 `retry-after` header in both its forms: a delay in seconds
139
+ * (never an epoch stamp, whatever its magnitude) or an HTTP-date.
140
+ * @param response - the failed response.
141
+ * @param now - the current epoch milliseconds.
142
+ * @returns epoch milliseconds of the reset, or undefined when absent or unusable.
143
+ */
144
+ export function retryAfterInstant(response, now) {
145
+ const raw = response.headers.get('retry-after');
146
+ if (raw === null)
147
+ return undefined;
148
+ const trimmed = raw.trim();
149
+ if (trimmed.length === 0)
150
+ return undefined;
151
+ const seconds = Number(trimmed);
152
+ if (Number.isFinite(seconds))
153
+ return seconds > 0 ? now + seconds * 1_000 : undefined;
154
+ const parsed = Date.parse(trimmed);
155
+ return Number.isFinite(parsed) ? parsed : undefined;
156
+ }
157
+ /**
158
+ * Parse a response body as JSON without throwing on the non-JSON bodies
159
+ * providers occasionally return under load (an HTML gateway page, say).
160
+ * @param body - the complete response body.
161
+ * @returns the parsed value, or undefined when the body is not JSON.
162
+ */
163
+ export function jsonBody(body) {
164
+ if (body.length === 0)
165
+ return undefined;
166
+ try {
167
+ return JSON.parse(body);
168
+ }
169
+ catch {
170
+ // Only swallow body parsing: header-derived signals still apply.
171
+ return undefined;
172
+ }
173
+ }
174
+ /** How deep {@link resetFromFields} walks; every observed payload nests one or two levels. */
175
+ const MAX_BODY_DEPTH = 4;
176
+ /**
177
+ * Find a reset instant under any of the named keys, anywhere in a parsed body.
178
+ *
179
+ * The search is by key rather than by path on purpose: providers move the same
180
+ * field between containers (`detail`, `error`, top level) across endpoints and
181
+ * versions, and a path-shaped reader silently stops working when they do. Only
182
+ * the key list is provider-specific.
183
+ * @param value - the parsed body, or any nested value.
184
+ * @param keys - field names this provider uses for a reset or delay.
185
+ * @param now - the current epoch milliseconds.
186
+ * @param depth - remaining recursion depth.
187
+ * @returns the earliest instant found, or undefined when no key matched.
188
+ */
189
+ export function resetFromFields(value, keys, now, depth = MAX_BODY_DEPTH) {
190
+ if (depth <= 0 || value === null || typeof value !== 'object')
191
+ return undefined;
192
+ let earliest;
193
+ const consider = (candidate) => {
194
+ if (candidate !== undefined && (earliest === undefined || candidate < earliest))
195
+ earliest = candidate;
196
+ };
197
+ if (Array.isArray(value)) {
198
+ for (const item of value)
199
+ consider(resetFromFields(item, keys, now, depth - 1));
200
+ return earliest;
201
+ }
202
+ for (const [key, nested] of Object.entries(value)) {
203
+ if (keys.includes(key))
204
+ consider(resetInstantFromValue(nested, now));
205
+ else
206
+ consider(resetFromFields(nested, keys, now, depth - 1));
207
+ }
208
+ return earliest;
209
+ }
210
+ /**
211
+ * The earliest of several candidate reset instants, ignoring absent ones. The
212
+ * earliest is the one that matters: it is the first moment any of the reported
213
+ * limits allows a request again.
214
+ * @param candidates - reset instants in no particular order.
215
+ * @returns the earliest instant, or undefined when every candidate is absent.
216
+ */
217
+ export function earliestReset(...candidates) {
218
+ let earliest;
219
+ for (const candidate of candidates) {
220
+ if (candidate === undefined)
221
+ continue;
222
+ if (earliest === undefined || candidate < earliest)
223
+ earliest = candidate;
224
+ }
225
+ return earliest;
226
+ }
227
+ /**
228
+ * Turn a reset instant into the wait to report as `providerRetryAfterMs`.
229
+ *
230
+ * Deliberately not capped: a reset beyond the policy's `maxDelayMs` makes the
231
+ * retry plugin delegate immediately, failing the turn at once with the real
232
+ * reset in the message, rather than clamping the wait down and burning the
233
+ * retry budget against a window that is still closed.
234
+ * @param instant - epoch milliseconds the window reopens.
235
+ * @param now - the current epoch milliseconds.
236
+ * @returns the wait in milliseconds, never below {@link MIN_WAIT_MS}.
237
+ */
238
+ export function waitFromReset(instant, now) {
239
+ return Math.max(MIN_WAIT_MS, instant - now + RESET_GRACE_MS);
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
+ /**
268
+ * The retry shape every subscription route starts from: Claude Code's own SDK
269
+ * numbers — ten retries after the first attempt, exponential backoff from 1s
270
+ * doubling per attempt, capped at 60s, plus 20% jitter.
271
+ *
272
+ * Shared across all four routes rather than kept to claude, because what these
273
+ * numbers are tuned for is the shape of a subscription endpoint — a consumer
274
+ * plan behind a session window, which sheds load in bursts and rewards an
275
+ * attempt that outlasts them — and that is the same on all four. The dsh-llm
276
+ * defaults (5 retries from 500ms to 10s) give up after about fifteen seconds,
277
+ * which is short for that.
278
+ *
279
+ * The 60s cap governs local backoff only: a disclosed rate-limit reset is
280
+ * accepted up to the configured wait ceiling instead.
281
+ */
282
+ export const DEFAULT_RETRY = Object.freeze({
283
+ maxRetries: 10,
284
+ initialDelayMs: 1_000,
285
+ maxDelayMs: 60_000,
286
+ jitterRatio: 0.2,
287
+ });
288
+ /** Waiting behavior a route falls back to when the plugin passed none (waiting on, six-hour ceiling). */
289
+ export const DEFAULT_RATE_LIMIT_WAIT = Object.freeze({
290
+ wait: true,
291
+ maxWaitMs: DEFAULT_RATE_LIMIT_MAX_WAIT_MS,
292
+ });
293
+ /**
294
+ * Validate and default the rate-limit waiting config.
295
+ * @param config - the raw plugin config section, when present.
296
+ * @param path - diagnostic path naming the config that owns the value.
297
+ * @returns the resolved, immutable behavior.
298
+ */
299
+ export function resolveRateLimitWait(config, path) {
300
+ const wait = config?.wait ?? true;
301
+ const maxWaitMs = config?.maxWaitMs ?? DEFAULT_RATE_LIMIT_MAX_WAIT_MS;
302
+ if (!Number.isFinite(maxWaitMs) || maxWaitMs <= 0) {
303
+ throw new Error(`${path}.maxWaitMs must be a positive finite number of milliseconds`);
304
+ }
305
+ if (maxWaitMs > MAX_TIMER_DELAY_MS) {
306
+ throw new Error(`${path}.maxWaitMs must be no greater than ${String(MAX_TIMER_DELAY_MS)} (the maximum schedulable delay)`);
307
+ }
308
+ return Object.freeze({ wait, maxWaitMs });
309
+ }
310
+ /**
311
+ * Resolve one route's retry policy, widening the delay ceiling to the
312
+ * configured wait so a disclosed reset hours out is accepted rather than
313
+ * refused.
314
+ *
315
+ * The ceiling is shared with local exponential backoff, so widening it also
316
+ * raises how long an unrelated transient failure may back off for. That stays
317
+ * bounded by the finite retry budget — the claude route's ten retries reach
318
+ * 512 s per attempt at most — and it only governs when the provider disclosed
319
+ * nothing, which is exactly the case where a longer wait is the safer guess.
320
+ * @param defaults - the route's retry shape.
321
+ * @param rateLimit - resolved waiting behavior.
322
+ * @param path - diagnostic path naming the provider route.
323
+ * @returns the policy to report from `providerRetryPolicy`.
324
+ */
325
+ export function subscriptionRetryPolicy(defaults, rateLimit, path) {
326
+ const maxDelayMs = rateLimit.wait
327
+ ? Math.max(defaults.maxDelayMs, rateLimit.maxWaitMs)
328
+ : defaults.maxDelayMs;
329
+ return resolveRetryPolicy({
330
+ mode: 'normal',
331
+ maxRetries: defaults.maxRetries,
332
+ backoff: {
333
+ initialDelayMs: defaults.initialDelayMs,
334
+ maxDelayMs,
335
+ jitterRatio: defaults.jitterRatio,
336
+ },
337
+ }, path);
338
+ }