@leaves615/dsh-llm-ctl 0.1.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.
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Per-provider concurrency resolution.
3
+ *
4
+ * The wire value {@link UNLIMITED_CONCURRENCY} (`0`) means "no cap". Inside
5
+ * the gate it is normalized to `Infinity`, so every existing comparison
6
+ * (`active < limit`) and ETA estimate keeps working without a special case.
7
+ * State payloads and settings always carry the wire value, never `Infinity`
8
+ * (which JSON would silently turn into `null`).
9
+ *
10
+ * @module dsh-llm-ctl/concurrency
11
+ */
12
+ /** Wire value meaning "no concurrency cap" in settings, routes, and state. */
13
+ export declare const UNLIMITED_CONCURRENCY = 0;
14
+ /**
15
+ * Resolve one provider's effective concurrency.
16
+ *
17
+ * An explicit entry wins (including `0` = unlimited for that provider);
18
+ * otherwise the table's `default` applies; without either, the provider is
19
+ * unlimited. There is deliberately no special case for free/shared routes:
20
+ * fragility there is handled by rate-limit cooldown, not by a default cap.
21
+ *
22
+ * @param table - per-provider table; the reserved key `default` applies to
23
+ * every provider without an explicit entry. `0` (or a negative number,
24
+ * defensively) means unlimited.
25
+ * @param provider - provider id to resolve.
26
+ * @returns the effective cap, or `Infinity` when uncapped.
27
+ */
28
+ export declare function concurrencyFor(table: Record<string, number>, provider: string): number;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Per-provider concurrency resolution.
3
+ *
4
+ * The wire value {@link UNLIMITED_CONCURRENCY} (`0`) means "no cap". Inside
5
+ * the gate it is normalized to `Infinity`, so every existing comparison
6
+ * (`active < limit`) and ETA estimate keeps working without a special case.
7
+ * State payloads and settings always carry the wire value, never `Infinity`
8
+ * (which JSON would silently turn into `null`).
9
+ *
10
+ * @module dsh-llm-ctl/concurrency
11
+ */
12
+ /** Wire value meaning "no concurrency cap" in settings, routes, and state. */
13
+ export const UNLIMITED_CONCURRENCY = 0;
14
+ /**
15
+ * Resolve one provider's effective concurrency.
16
+ *
17
+ * An explicit entry wins (including `0` = unlimited for that provider);
18
+ * otherwise the table's `default` applies; without either, the provider is
19
+ * unlimited. There is deliberately no special case for free/shared routes:
20
+ * fragility there is handled by rate-limit cooldown, not by a default cap.
21
+ *
22
+ * @param table - per-provider table; the reserved key `default` applies to
23
+ * every provider without an explicit entry. `0` (or a negative number,
24
+ * defensively) means unlimited.
25
+ * @param provider - provider id to resolve.
26
+ * @returns the effective cap, or `Infinity` when uncapped.
27
+ */
28
+ export function concurrencyFor(table, provider) {
29
+ const exact = table[provider];
30
+ if (exact !== undefined)
31
+ return exact <= 0 ? Infinity : exact;
32
+ const fallback = table['default'];
33
+ if (fallback === undefined || fallback <= 0)
34
+ return Infinity;
35
+ return fallback;
36
+ }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Plugin configuration schema and normalization.
3
+ *
4
+ * @module dsh-llm-ctl/config
5
+ */
6
+ import z from '@deepseek-ai/schemastery';
7
+ import type { BackoffConfig } from './delay.ts';
8
+ export { UNLIMITED_CONCURRENCY, concurrencyFor } from './concurrency.ts';
9
+ /** Single wait budget covering both queue patience and honored cooldown. */
10
+ export declare const DEFAULT_MAX_WAIT_MS = 120000;
11
+ /** Default bounded budget for standalone recovery when no retry executor is mounted. */
12
+ export declare const DEFAULT_REACTIVE_RETRIES = 3;
13
+ /** Local backoff used when a provider supplies no hint. */
14
+ export declare const BackoffConfigSchema: z<Schemastery.ObjectS<{
15
+ initialDelayMs: z<number, number>;
16
+ maxDelayMs: z<number, number>;
17
+ jitterRatio: z<number, number>;
18
+ }>, Schemastery.ObjectT<{
19
+ initialDelayMs: z<number, number>;
20
+ maxDelayMs: z<number, number>;
21
+ jitterRatio: z<number, number>;
22
+ }>>;
23
+ /** Admission queue configuration. */
24
+ export declare const QueueConfigSchema: z<Schemastery.ObjectS<{
25
+ /**
26
+ * Per-provider concurrency. The reserved key `default` applies to every
27
+ * provider without an explicit entry; free routes fall back to 1.
28
+ */
29
+ perProviderConcurrency: z<import("@deepseek-ai/cosmokit").Dict<number, string>, import("@deepseek-ai/cosmokit").Dict<number, string>>;
30
+ maxQueueDepth: z<number, number>;
31
+ maxWaitMs: z<number, number>;
32
+ honorRetryAfter: z<boolean, boolean>;
33
+ backoff: z<Schemastery.ObjectS<{
34
+ initialDelayMs: z<number, number>;
35
+ maxDelayMs: z<number, number>;
36
+ jitterRatio: z<number, number>;
37
+ }>, Schemastery.ObjectT<{
38
+ initialDelayMs: z<number, number>;
39
+ maxDelayMs: z<number, number>;
40
+ jitterRatio: z<number, number>;
41
+ }>>;
42
+ }>, Schemastery.ObjectT<{
43
+ /**
44
+ * Per-provider concurrency. The reserved key `default` applies to every
45
+ * provider without an explicit entry; free routes fall back to 1.
46
+ */
47
+ perProviderConcurrency: z<import("@deepseek-ai/cosmokit").Dict<number, string>, import("@deepseek-ai/cosmokit").Dict<number, string>>;
48
+ maxQueueDepth: z<number, number>;
49
+ maxWaitMs: z<number, number>;
50
+ honorRetryAfter: z<boolean, boolean>;
51
+ backoff: z<Schemastery.ObjectS<{
52
+ initialDelayMs: z<number, number>;
53
+ maxDelayMs: z<number, number>;
54
+ jitterRatio: z<number, number>;
55
+ }>, Schemastery.ObjectT<{
56
+ initialDelayMs: z<number, number>;
57
+ maxDelayMs: z<number, number>;
58
+ jitterRatio: z<number, number>;
59
+ }>>;
60
+ }>>;
61
+ /** Visibility presets owned by the composition, not by the user layer. */
62
+ export declare const VisibilityConfigSchema: z<Schemastery.ObjectS<{
63
+ /** Read-only glob patterns; only \`*\` is a metacharacter. */
64
+ hiddenPatterns: z<string[], string[]>;
65
+ }>, Schemastery.ObjectT<{
66
+ /** Read-only glob patterns; only \`*\` is a metacharacter. */
67
+ hiddenPatterns: z<string[], string[]>;
68
+ }>>;
69
+ /** Whole-plugin configuration. */
70
+ export declare const Config: z<Schemastery.ObjectS<{
71
+ queue: z<Schemastery.ObjectS<{
72
+ /**
73
+ * Per-provider concurrency. The reserved key `default` applies to every
74
+ * provider without an explicit entry; free routes fall back to 1.
75
+ */
76
+ perProviderConcurrency: z<import("@deepseek-ai/cosmokit").Dict<number, string>, import("@deepseek-ai/cosmokit").Dict<number, string>>;
77
+ maxQueueDepth: z<number, number>;
78
+ maxWaitMs: z<number, number>;
79
+ honorRetryAfter: z<boolean, boolean>;
80
+ backoff: z<Schemastery.ObjectS<{
81
+ initialDelayMs: z<number, number>;
82
+ maxDelayMs: z<number, number>;
83
+ jitterRatio: z<number, number>;
84
+ }>, Schemastery.ObjectT<{
85
+ initialDelayMs: z<number, number>;
86
+ maxDelayMs: z<number, number>;
87
+ jitterRatio: z<number, number>;
88
+ }>>;
89
+ }>, Schemastery.ObjectT<{
90
+ /**
91
+ * Per-provider concurrency. The reserved key `default` applies to every
92
+ * provider without an explicit entry; free routes fall back to 1.
93
+ */
94
+ perProviderConcurrency: z<import("@deepseek-ai/cosmokit").Dict<number, string>, import("@deepseek-ai/cosmokit").Dict<number, string>>;
95
+ maxQueueDepth: z<number, number>;
96
+ maxWaitMs: z<number, number>;
97
+ honorRetryAfter: z<boolean, boolean>;
98
+ backoff: z<Schemastery.ObjectS<{
99
+ initialDelayMs: z<number, number>;
100
+ maxDelayMs: z<number, number>;
101
+ jitterRatio: z<number, number>;
102
+ }>, Schemastery.ObjectT<{
103
+ initialDelayMs: z<number, number>;
104
+ maxDelayMs: z<number, number>;
105
+ jitterRatio: z<number, number>;
106
+ }>>;
107
+ }>>;
108
+ visibility: z<Schemastery.ObjectS<{
109
+ /** Read-only glob patterns; only \`*\` is a metacharacter. */
110
+ hiddenPatterns: z<string[], string[]>;
111
+ }>, Schemastery.ObjectT<{
112
+ /** Read-only glob patterns; only \`*\` is a metacharacter. */
113
+ hiddenPatterns: z<string[], string[]>;
114
+ }>>;
115
+ /**
116
+ * Standalone recovery budget. `auto` engages only when the
117
+ * `agent/request-error` waterfall reaches no downstream retry decision;
118
+ * `off` never retries; a number caps retries per step.
119
+ */
120
+ reactiveRetry: z<number | "auto" | "off", number | "auto" | "off">;
121
+ }>, Schemastery.ObjectT<{
122
+ queue: z<Schemastery.ObjectS<{
123
+ /**
124
+ * Per-provider concurrency. The reserved key `default` applies to every
125
+ * provider without an explicit entry; free routes fall back to 1.
126
+ */
127
+ perProviderConcurrency: z<import("@deepseek-ai/cosmokit").Dict<number, string>, import("@deepseek-ai/cosmokit").Dict<number, string>>;
128
+ maxQueueDepth: z<number, number>;
129
+ maxWaitMs: z<number, number>;
130
+ honorRetryAfter: z<boolean, boolean>;
131
+ backoff: z<Schemastery.ObjectS<{
132
+ initialDelayMs: z<number, number>;
133
+ maxDelayMs: z<number, number>;
134
+ jitterRatio: z<number, number>;
135
+ }>, Schemastery.ObjectT<{
136
+ initialDelayMs: z<number, number>;
137
+ maxDelayMs: z<number, number>;
138
+ jitterRatio: z<number, number>;
139
+ }>>;
140
+ }>, Schemastery.ObjectT<{
141
+ /**
142
+ * Per-provider concurrency. The reserved key `default` applies to every
143
+ * provider without an explicit entry; free routes fall back to 1.
144
+ */
145
+ perProviderConcurrency: z<import("@deepseek-ai/cosmokit").Dict<number, string>, import("@deepseek-ai/cosmokit").Dict<number, string>>;
146
+ maxQueueDepth: z<number, number>;
147
+ maxWaitMs: z<number, number>;
148
+ honorRetryAfter: z<boolean, boolean>;
149
+ backoff: z<Schemastery.ObjectS<{
150
+ initialDelayMs: z<number, number>;
151
+ maxDelayMs: z<number, number>;
152
+ jitterRatio: z<number, number>;
153
+ }>, Schemastery.ObjectT<{
154
+ initialDelayMs: z<number, number>;
155
+ maxDelayMs: z<number, number>;
156
+ jitterRatio: z<number, number>;
157
+ }>>;
158
+ }>>;
159
+ visibility: z<Schemastery.ObjectS<{
160
+ /** Read-only glob patterns; only \`*\` is a metacharacter. */
161
+ hiddenPatterns: z<string[], string[]>;
162
+ }>, Schemastery.ObjectT<{
163
+ /** Read-only glob patterns; only \`*\` is a metacharacter. */
164
+ hiddenPatterns: z<string[], string[]>;
165
+ }>>;
166
+ /**
167
+ * Standalone recovery budget. `auto` engages only when the
168
+ * `agent/request-error` waterfall reaches no downstream retry decision;
169
+ * `off` never retries; a number caps retries per step.
170
+ */
171
+ reactiveRetry: z<number | "auto" | "off", number | "auto" | "off">;
172
+ }>>;
173
+ /** Normalized configuration used by the runtime. */
174
+ export interface ResolvedConfig {
175
+ queue: {
176
+ perProviderConcurrency: Record<string, number>;
177
+ maxQueueDepth: number;
178
+ maxWaitMs: number;
179
+ honorRetryAfter: boolean;
180
+ backoff: BackoffConfig;
181
+ };
182
+ /** `auto` is resolved to a numeric cap; 0 means disabled. */
183
+ reactiveRetryLimit: number;
184
+ reactiveRetryMode: 'auto' | 'off' | number;
185
+ /** Composition-level hide patterns (read-only for the user layer). */
186
+ hiddenPatterns: readonly string[];
187
+ }
188
+ /** Configuration shape accepted by {@link resolveConfig}. */
189
+ export interface ConfigInput {
190
+ queue?: {
191
+ perProviderConcurrency?: Record<string, number>;
192
+ maxQueueDepth?: number;
193
+ maxWaitMs?: number;
194
+ honorRetryAfter?: boolean;
195
+ backoff?: Partial<BackoffConfig>;
196
+ };
197
+ reactiveRetry?: 'auto' | 'off' | number;
198
+ visibility?: {
199
+ hiddenPatterns?: readonly string[];
200
+ };
201
+ }
202
+ /** Normalize raw plugin config, applying every documented default. */
203
+ export declare function resolveConfig(input: ConfigInput | undefined): ResolvedConfig;
package/lib/config.js ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Plugin configuration schema and normalization.
3
+ *
4
+ * @module dsh-llm-ctl/config
5
+ */
6
+ import z from '@deepseek-ai/schemastery';
7
+ export { UNLIMITED_CONCURRENCY, concurrencyFor } from "./concurrency.js";
8
+ /** Single wait budget covering both queue patience and honored cooldown. */
9
+ export const DEFAULT_MAX_WAIT_MS = 120_000;
10
+ /** Default bounded budget for standalone recovery when no retry executor is mounted. */
11
+ export const DEFAULT_REACTIVE_RETRIES = 3;
12
+ /** Local backoff used when a provider supplies no hint. */
13
+ export const BackoffConfigSchema = z.object({
14
+ initialDelayMs: z.number().min(0).default(500),
15
+ maxDelayMs: z.number().min(0).default(10_000),
16
+ jitterRatio: z.number().min(0).max(1).default(0.1),
17
+ });
18
+ /** Admission queue configuration. */
19
+ export const QueueConfigSchema = z.object({
20
+ /**
21
+ * Per-provider concurrency. The reserved key `default` applies to every
22
+ * provider without an explicit entry; free routes fall back to 1.
23
+ */
24
+ perProviderConcurrency: z.dict(z.number().step(1).min(0)).default({}),
25
+ maxQueueDepth: z.number().step(1).min(1).default(50),
26
+ maxWaitMs: z.number().min(0).default(DEFAULT_MAX_WAIT_MS),
27
+ honorRetryAfter: z.boolean().default(true),
28
+ backoff: BackoffConfigSchema,
29
+ });
30
+ /** Visibility presets owned by the composition, not by the user layer. */
31
+ export const VisibilityConfigSchema = z.object({
32
+ /** Read-only glob patterns; only \`*\` is a metacharacter. */
33
+ hiddenPatterns: z.array(z.string()).default([]),
34
+ });
35
+ /** Whole-plugin configuration. */
36
+ export const Config = z.object({
37
+ queue: QueueConfigSchema,
38
+ visibility: VisibilityConfigSchema,
39
+ /**
40
+ * Standalone recovery budget. `auto` engages only when the
41
+ * `agent/request-error` waterfall reaches no downstream retry decision;
42
+ * `off` never retries; a number caps retries per step.
43
+ */
44
+ reactiveRetry: z.union(['auto', 'off', z.number().step(1).min(0)]).default('auto'),
45
+ });
46
+ /** Normalize raw plugin config, applying every documented default. */
47
+ export function resolveConfig(input) {
48
+ const queue = input?.queue ?? {};
49
+ const backoffInput = queue.backoff ?? {};
50
+ const reactiveRetryMode = input?.reactiveRetry ?? 'auto';
51
+ return {
52
+ queue: {
53
+ perProviderConcurrency: { ...(queue.perProviderConcurrency ?? {}) },
54
+ maxQueueDepth: queue.maxQueueDepth ?? 50,
55
+ maxWaitMs: queue.maxWaitMs ?? DEFAULT_MAX_WAIT_MS,
56
+ honorRetryAfter: queue.honorRetryAfter ?? true,
57
+ backoff: {
58
+ initialDelayMs: backoffInput.initialDelayMs ?? 500,
59
+ maxDelayMs: backoffInput.maxDelayMs ?? 10_000,
60
+ jitterRatio: backoffInput.jitterRatio ?? 0.1,
61
+ },
62
+ },
63
+ reactiveRetryMode,
64
+ hiddenPatterns: [...(input?.visibility?.hiddenPatterns ?? [])],
65
+ reactiveRetryLimit: reactiveRetryMode === 'off' ? 0 : reactiveRetryMode === 'auto' ? DEFAULT_REACTIVE_RETRIES : reactiveRetryMode,
66
+ };
67
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Host half of the browser control surface: a Typert Remote namespace that
3
+ * reports queue state and cancels a queued request.
4
+ *
5
+ * The `@Remote` marker is applied programmatically rather than with decorator
6
+ * syntax: the marker is a prototype descriptor, and avoiding decorators keeps
7
+ * the source runnable by Node's type-stripping test runner without a build step.
8
+ *
9
+ * @module dsh-llm-ctl/controller
10
+ */
11
+ import type { Context } from '@deepseek-ai/cordis';
12
+ import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
13
+ import type { CtlEvent } from './events.ts';
14
+ import type { GateSnapshot } from './queue.ts';
15
+ /** Everything the browser may read about the control plane. */
16
+ export interface LlmCtlSnapshot {
17
+ at: number;
18
+ queue: GateSnapshot;
19
+ events: CtlEvent[];
20
+ reactive: {
21
+ mode: 'auto' | 'off' | number;
22
+ limit: number;
23
+ };
24
+ }
25
+ /** Dependencies supplied by the host plugin. */
26
+ export interface ControllerDeps {
27
+ snapshot: () => LlmCtlSnapshot;
28
+ cancel: (queueId: string) => boolean;
29
+ }
30
+ /** `ctx.remote.llmCtl` — the namespace the web client polls. */
31
+ export declare class LlmCtlController extends TypertRemoteService {
32
+ private readonly deps;
33
+ constructor(ctx: Context, deps: ControllerDeps);
34
+ /** Current queue, cooldown, and recent control-plane facts. */
35
+ snapshot(): LlmCtlSnapshot;
36
+ /** Cancel one still-queued request; false when it already started or vanished. */
37
+ cancel(request: {
38
+ queueId: string;
39
+ }): {
40
+ cancelled: boolean;
41
+ };
42
+ }
@@ -0,0 +1,51 @@
1
+ import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
2
+ /**
3
+ * Mark one public instance method as a Remote export.
4
+ *
5
+ * @param instance - service instance whose prototype receives the marker.
6
+ * @param name - public method name exported under the namespace.
7
+ */
8
+ function markRemote(instance, name) {
9
+ const method = instance[name];
10
+ if (typeof method !== 'function')
11
+ throw new TypeError(`llm-ctl: remote method "${name}" is missing`);
12
+ const context = {
13
+ kind: 'method',
14
+ name,
15
+ static: false,
16
+ private: false,
17
+ access: {
18
+ has: (target) => name in target,
19
+ get: (target) => target[name],
20
+ },
21
+ metadata: {},
22
+ addInitializer(initializer) {
23
+ initializer.call(instance);
24
+ },
25
+ };
26
+ Remote(method, context);
27
+ }
28
+ /** `ctx.remote.llmCtl` — the namespace the web client polls. */
29
+ export class LlmCtlController extends TypertRemoteService {
30
+ deps;
31
+ constructor(ctx, deps) {
32
+ super(ctx, 'llmCtlController', { namespace: 'llmCtl' });
33
+ this.deps = deps;
34
+ markRemote(this, 'snapshot');
35
+ markRemote(this, 'cancel');
36
+ }
37
+ /** Current queue, cooldown, and recent control-plane facts. */
38
+ snapshot() {
39
+ return this.deps.snapshot();
40
+ }
41
+ /** Cancel one still-queued request; false when it already started or vanished. */
42
+ cancel(request) {
43
+ if (request === null ||
44
+ typeof request !== 'object' ||
45
+ typeof request.queueId !== 'string' ||
46
+ request.queueId.length === 0) {
47
+ return { cancelled: false };
48
+ }
49
+ return { cancelled: this.deps.cancel(request.queueId) };
50
+ }
51
+ }
package/lib/delay.d.ts ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Delay resolution ladder for rate-limit recovery.
3
+ *
4
+ * Every source normalizes to milliseconds. Levels 2-6 need an adapter to expose
5
+ * raw response headers through `LlmError.details`; the parsers ship now so M2 is
6
+ * only wiring, while M1 uses level 1 (the adapter's parsed hint) and level 7
7
+ * (local bounded backoff).
8
+ *
9
+ * @module dsh-llm-ctl/delay
10
+ */
11
+ /** Local bounded exponential backoff with symmetric jitter. */
12
+ export interface BackoffConfig {
13
+ initialDelayMs: number;
14
+ maxDelayMs: number;
15
+ jitterRatio: number;
16
+ }
17
+ /** Where the resolved delay came from, for observability and tests. */
18
+ export type DelaySource = 'provider-retry-after-ms' | 'retry-after-ms' | 'retry-after' | 'ratelimit-reset' | 'go-duration' | 'rfc3339-reset' | 'backoff';
19
+ /** One resolved wait, already normalized to milliseconds. */
20
+ export interface DelayResolution {
21
+ delayMs: number;
22
+ source: DelaySource;
23
+ /** True when the provider asked for longer than the single wait budget. */
24
+ overBudget: boolean;
25
+ }
26
+ /** Inputs to one delay resolution. */
27
+ export interface DelayLadderInput {
28
+ /** Delay the adapter already parsed out of the provider response. */
29
+ providerRetryAfterMs?: number | undefined;
30
+ /**
31
+ * Raw provider response headers. M1 adapters do not expose these; the ladder
32
+ * accepts them so M2 only has to pass them through.
33
+ */
34
+ headers?: Record<string, string | undefined> | undefined;
35
+ /** 1-based attempt number used by the local backoff. */
36
+ attempt: number;
37
+ backoff: BackoffConfig;
38
+ /** Single wait budget shared by queueing and cooldown. */
39
+ maxWaitMs: number;
40
+ honorRetryAfter: boolean;
41
+ random: () => number;
42
+ /** Clock used by absolute-time headers; defaults to Date.now. */
43
+ now?: () => number;
44
+ }
45
+ /** Seconds or HTTP-date, per RFC 9110. Returns undefined for unusable values. */
46
+ export declare function parseRetryAfterHeader(value: string | undefined, now?: number): number | undefined;
47
+ /** `retry-after-ms` carries milliseconds directly. */
48
+ export declare function parseRetryAfterMsHeader(value: string | undefined): number | undefined;
49
+ /**
50
+ * A numeric reset header: small values are seconds, large ones are an epoch in
51
+ * seconds (OpenRouter). The 3600s boundary keeps ordinary second values exact.
52
+ */
53
+ export declare function parseNumericResetHeader(value: string | undefined, now?: number): number | undefined;
54
+ /** Go duration strings used by OpenAI and Groq reset headers, e.g. `6m0s`. */
55
+ export declare function parseGoDurationMs(value: string | undefined): number | undefined;
56
+ /** RFC 3339 absolute reset instant (Anthropic), converted against the local clock. */
57
+ export declare function parseRfc3339ResetHeader(value: string | undefined, now?: number): number | undefined;
58
+ /** Local bounded exponential backoff with symmetric jitter. */
59
+ export declare function backoffDelay(attempt: number, config: BackoffConfig, random: () => number): number;
60
+ /**
61
+ * Resolve the wait for one failed request.
62
+ *
63
+ * Priority: adapter-parsed provider hint, then raw headers (ms, Retry-After,
64
+ * numeric reset, Go duration, RFC 3339 reset), then local backoff. A resolved
65
+ * delay above `maxWaitMs` is reported as over budget so the caller can fail
66
+ * fast instead of waiting out a budget it can never satisfy.
67
+ */
68
+ export declare function resolveDelay(input: DelayLadderInput): DelayResolution;
package/lib/delay.js ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Delay resolution ladder for rate-limit recovery.
3
+ *
4
+ * Every source normalizes to milliseconds. Levels 2-6 need an adapter to expose
5
+ * raw response headers through `LlmError.details`; the parsers ship now so M2 is
6
+ * only wiring, while M1 uses level 1 (the adapter's parsed hint) and level 7
7
+ * (local bounded backoff).
8
+ *
9
+ * @module dsh-llm-ctl/delay
10
+ */
11
+ /** Seconds or HTTP-date, per RFC 9110. Returns undefined for unusable values. */
12
+ export function parseRetryAfterHeader(value, now = Date.now()) {
13
+ if (value === undefined)
14
+ return undefined;
15
+ const trimmed = value.trim();
16
+ if (trimmed.length === 0)
17
+ return undefined;
18
+ if (/^\d+$/.test(trimmed)) {
19
+ const seconds = Number(trimmed);
20
+ return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : undefined;
21
+ }
22
+ const at = Date.parse(trimmed);
23
+ if (!Number.isFinite(at))
24
+ return undefined;
25
+ const delta = at - now;
26
+ return delta > 0 ? delta : undefined;
27
+ }
28
+ /** `retry-after-ms` carries milliseconds directly. */
29
+ export function parseRetryAfterMsHeader(value) {
30
+ if (value === undefined)
31
+ return undefined;
32
+ const ms = Number(value.trim());
33
+ return Number.isFinite(ms) && ms > 0 ? ms : undefined;
34
+ }
35
+ /**
36
+ * A numeric reset header: small values are seconds, large ones are an epoch in
37
+ * seconds (OpenRouter). The 3600s boundary keeps ordinary second values exact.
38
+ */
39
+ export function parseNumericResetHeader(value, now = Date.now()) {
40
+ if (value === undefined)
41
+ return undefined;
42
+ const raw = Number(value.trim());
43
+ if (!Number.isFinite(raw) || raw <= 0)
44
+ return undefined;
45
+ if (raw > 3600) {
46
+ const delta = raw * 1000 - now;
47
+ return delta > 0 ? delta : undefined;
48
+ }
49
+ return raw * 1000;
50
+ }
51
+ /** Go duration strings used by OpenAI and Groq reset headers, e.g. `6m0s`. */
52
+ export function parseGoDurationMs(value) {
53
+ if (value === undefined)
54
+ return undefined;
55
+ const trimmed = value.trim();
56
+ if (trimmed.length === 0)
57
+ return undefined;
58
+ const units = { ns: 1e-6, us: 1e-3, 'µs': 1e-3, ms: 1, s: 1000, m: 60_000, h: 3_600_000 };
59
+ const pattern = /(\d+(?:\.\d+)?)(ns|us|µs|ms|s|m|h)/g;
60
+ let total = 0;
61
+ let matched = 0;
62
+ let consumed = 0;
63
+ for (const match of trimmed.matchAll(pattern)) {
64
+ const amount = Number(match[1]);
65
+ const unit = units[match[2] ?? ''];
66
+ if (!Number.isFinite(amount) || unit === undefined)
67
+ return undefined;
68
+ total += amount * unit;
69
+ matched += 1;
70
+ consumed += match[0].length;
71
+ }
72
+ if (matched === 0 || consumed !== trimmed.length)
73
+ return undefined;
74
+ return total > 0 ? total : undefined;
75
+ }
76
+ /** RFC 3339 absolute reset instant (Anthropic), converted against the local clock. */
77
+ export function parseRfc3339ResetHeader(value, now = Date.now()) {
78
+ if (value === undefined)
79
+ return undefined;
80
+ const at = Date.parse(value.trim());
81
+ if (!Number.isFinite(at))
82
+ return undefined;
83
+ const delta = at - now;
84
+ return delta > 0 ? delta : undefined;
85
+ }
86
+ /** Local bounded exponential backoff with symmetric jitter. */
87
+ export function backoffDelay(attempt, config, random) {
88
+ const exponent = Math.min(Math.max(attempt - 1, 0), 16);
89
+ const exponential = Math.min(config.initialDelayMs * 2 ** exponent, config.maxDelayMs);
90
+ const jitter = 1 - config.jitterRatio + 2 * config.jitterRatio * random();
91
+ return Math.max(0, Math.min(exponential * jitter, config.maxDelayMs));
92
+ }
93
+ /**
94
+ * Resolve the wait for one failed request.
95
+ *
96
+ * Priority: adapter-parsed provider hint, then raw headers (ms, Retry-After,
97
+ * numeric reset, Go duration, RFC 3339 reset), then local backoff. A resolved
98
+ * delay above `maxWaitMs` is reported as over budget so the caller can fail
99
+ * fast instead of waiting out a budget it can never satisfy.
100
+ */
101
+ export function resolveDelay(input) {
102
+ const nowMs = (input.now ?? Date.now)();
103
+ const finish = (delayMs, source) => ({
104
+ delayMs,
105
+ source,
106
+ overBudget: delayMs > input.maxWaitMs,
107
+ });
108
+ if (input.honorRetryAfter) {
109
+ const hint = input.providerRetryAfterMs;
110
+ if (hint !== undefined && Number.isFinite(hint) && hint > 0)
111
+ return finish(hint, 'provider-retry-after-ms');
112
+ const headers = input.headers;
113
+ if (headers !== undefined) {
114
+ const read = (name) => headers[name] ?? headers[name.toLowerCase()];
115
+ const afterMs = parseRetryAfterMsHeader(read('retry-after-ms'));
116
+ if (afterMs !== undefined)
117
+ return finish(afterMs, 'retry-after-ms');
118
+ const after = parseRetryAfterHeader(read('retry-after'), nowMs);
119
+ if (after !== undefined)
120
+ return finish(after, 'retry-after');
121
+ const numericReset = parseNumericResetHeader(read('x-ratelimit-reset'), nowMs);
122
+ if (numericReset !== undefined)
123
+ return finish(numericReset, 'ratelimit-reset');
124
+ const goDuration = parseGoDurationMs(read('x-ratelimit-reset-requests') ?? read('x-ratelimit-reset-tokens'));
125
+ if (goDuration !== undefined)
126
+ return finish(goDuration, 'go-duration');
127
+ const rfcReset = parseRfc3339ResetHeader(read('anthropic-ratelimit-requests-reset'), nowMs) ??
128
+ parseRfc3339ResetHeader(read('anthropic-ratelimit-tokens-reset'), nowMs);
129
+ if (rfcReset !== undefined)
130
+ return finish(rfcReset, 'rfc3339-reset');
131
+ }
132
+ }
133
+ return finish(backoffDelay(input.attempt, input.backoff, input.random), 'backoff');
134
+ }