@naced/llm-multi-rate-limiter 0.1.0 → 0.1.2

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,259 @@
1
+ /**
2
+ * Pure configuration-resolution helpers.
3
+ *
4
+ * Kept free of Cordis, schemastery and timers so the unit tests can exercise
5
+ * the precedence rules, the wildcard matcher and the 429 cooldown machine with
6
+ * plain objects.
7
+ *
8
+ * Precedence for one call (`provider/model`):
9
+ *
10
+ * models["<provider>/<model>"] exact model key
11
+ * > models["<pattern with *>"] wildcard model key (most specific first)
12
+ * > providers["<provider>"] provider-wide tier
13
+ * > defaults global tier
14
+ *
15
+ * @module dsh-llm-multi-rate-limiter/config-resolve
16
+ */
17
+
18
+ /** Sliding-window width. `maxRpm` is "per minute", so the window is a minute. */
19
+ export const WINDOW_MS = 60_000;
20
+
21
+ /** Chars that would change the meaning of the generated regexp. */
22
+ const REGEXP_SPECIALS = /[.*+?^${}()|[\]\\]/g;
23
+
24
+ function escapeRegExp(text) {
25
+ return text.replace(REGEXP_SPECIALS, "\\$&");
26
+ }
27
+
28
+ /**
29
+ * Compile a `*`-wildcard key into a matcher.
30
+ *
31
+ * `*` matches any run of characters that does not cross a `/`, so
32
+ * `openai/*` matches `openai/gpt-5.1` but not `openai/gpt-5.1/turbo`, and
33
+ * `*` alone matches every provider/model pair.
34
+ *
35
+ * @param {string} pattern
36
+ * @returns {RegExp}
37
+ */
38
+ export function compilePattern(pattern) {
39
+ const source = pattern.split("*").map(escapeRegExp).join("[^/]*");
40
+ return new RegExp(`^${source}$`);
41
+ }
42
+
43
+ /** True when `key` contains a wildcard and is therefore a pattern, not a key. */
44
+ export function isPattern(key) {
45
+ return key.includes("*");
46
+ }
47
+
48
+ /** Number of literal characters a pattern pins down — its specificity score. */
49
+ function specificity(pattern) {
50
+ return pattern.length - pattern.split("*").length + 1;
51
+ }
52
+
53
+ /**
54
+ * Find the override that applies to `key` in a `{ key: override }` dict.
55
+ *
56
+ * Exact keys always win over patterns. When several patterns match, the most
57
+ * specific one wins (most literal characters); remaining ties break on the
58
+ * longer pattern so the choice is stable across runs.
59
+ *
60
+ * @param {Record<string, object> | undefined} dict
61
+ * @param {string} key
62
+ * @returns {{ override: object, matched: string } | undefined}
63
+ */
64
+ export function matchOverride(dict, key) {
65
+ if (dict === null || typeof dict !== "object") return undefined;
66
+
67
+ const exact = dict[key];
68
+ if (exact !== null && typeof exact === "object") return { override: exact, matched: key };
69
+
70
+ /** @type {Array<{ pattern: string, override: object, score: number }>} */
71
+ const candidates = [];
72
+ for (const [pattern, override] of Object.entries(dict)) {
73
+ if (!isPattern(pattern)) continue;
74
+ if (override === null || typeof override !== "object") continue;
75
+ if (!compilePattern(pattern).test(key)) continue;
76
+ candidates.push({ pattern, override, score: specificity(pattern) });
77
+ }
78
+ if (candidates.length === 0) return undefined;
79
+
80
+ candidates.sort((a, b) => b.score - a.score || b.pattern.length - a.pattern.length);
81
+ return { override: candidates[0].override, matched: candidates[0].pattern };
82
+ }
83
+
84
+ /** Provider half of a `provider/model` key. */
85
+ export function providerOf(key) {
86
+ const slash = key.indexOf("/");
87
+ return slash === -1 ? key : key.slice(0, slash);
88
+ }
89
+
90
+ /**
91
+ * Flatten the four config tiers into the flat shape the strategies understand.
92
+ *
93
+ * @param {object} cfg plain config (`{ defaults, providers, models }`)
94
+ * @param {string} key `provider/model` of the call being limited
95
+ * @returns {{ maxConcurrent: number, maxRpm: number, burstSize: number,
96
+ * refillRate: number, windowMs: number, source: "model" | "provider" | "defaults",
97
+ * matchedKey: string }}
98
+ */
99
+ export function resolveModelConfig(cfg, key) {
100
+ const defaults = cfg?.defaults ?? {};
101
+ const modelHit = matchOverride(cfg?.models, key);
102
+ const providerId = providerOf(key);
103
+ const providerHit = matchOverride(cfg?.providers, providerId);
104
+
105
+ const model = modelHit?.override ?? {};
106
+ const provider = providerHit?.override ?? {};
107
+ const source = modelHit !== undefined ? "model" : providerHit !== undefined ? "provider" : "defaults";
108
+
109
+ const maxRpm = model.maxRpm ?? provider.maxRpm ?? defaults.maxRpm ?? 60;
110
+ // A model/provider that sets maxRpm but not refillRate gets a refill derived
111
+ // from ITS maxRpm, so "maxRpm: 20" alone behaves as 20 per minute. When the
112
+ // winning tier does not touch maxRpm, the narrower tier's refillRate (if any)
113
+ // is used as-is.
114
+ const explicitRefill = model.refillRate ?? provider.refillRate;
115
+ const refillRate = explicitRefill
116
+ ?? ((model.maxRpm ?? provider.maxRpm) != null
117
+ ? maxRpm / 60
118
+ : defaults.refillRate ?? maxRpm / 60);
119
+
120
+ return {
121
+ maxConcurrent: model.maxConcurrent ?? provider.maxConcurrent ?? defaults.maxConcurrent ?? 5,
122
+ maxRpm,
123
+ burstSize: model.burstSize ?? provider.burstSize ?? defaults.burstSize ?? 10,
124
+ refillRate,
125
+ windowMs: WINDOW_MS,
126
+ source,
127
+ matchedKey: modelHit?.matched ?? providerHit?.matched ?? "(defaults)",
128
+ };
129
+ }
130
+
131
+ /** Scale one resolved config down by `factor` (never below the usable floor). */
132
+ export function scaleConfig(resolved, factor) {
133
+ if (!(factor < 1)) return resolved;
134
+ return {
135
+ ...resolved,
136
+ maxRpm: Math.max(1, Math.round(resolved.maxRpm * factor)),
137
+ burstSize: Math.max(1, Math.round(resolved.burstSize * factor)),
138
+ refillRate: Math.max(0.01, resolved.refillRate * factor),
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Per-model 429 cooldown.
144
+ *
145
+ * A rate limiter that only counts requests cannot know that the provider
146
+ * throttled us for a reason of its own (a shared quota, a burst it did not
147
+ * like). Treating a `RATE_LIMIT` failure from the provider as a signal keeps
148
+ * the next calls from walking into the same wall: the model's rate is scaled
149
+ * down for `cooldownMs`, then restored after `recoverAfter` clean calls.
150
+ *
151
+ * Pure state machine: the caller supplies `now`, so tests need no clock.
152
+ */
153
+ export class CooldownTracker {
154
+ /** @type {Map<string, { until: number, factor: number, clean: number }>} */
155
+ #state = new Map();
156
+
157
+ /**
158
+ * @param {{ enabled?: boolean, cooldownMs?: number, factor?: number, recoverAfter?: number }} options
159
+ */
160
+ constructor(options = {}) {
161
+ this.enabled = options.enabled !== false;
162
+ /** How long a throttled model stays slowed down. */
163
+ this.cooldownMs = Number.isFinite(options.cooldownMs) ? options.cooldownMs : 30_000;
164
+ /** Rate multiplier applied while cooling (0 < factor < 1). */
165
+ this.factor = Number.isFinite(options.factor) ? Math.min(0.9, Math.max(0.05, options.factor)) : 0.5;
166
+ /** Clean calls required before the full rate is restored. */
167
+ this.recoverAfter = Number.isFinite(options.recoverAfter) ? Math.max(1, options.recoverAfter) : 3;
168
+ }
169
+
170
+ /** Re-read policy fields from the live config. */
171
+ configure(options = {}) {
172
+ if (typeof options.enabled === "boolean") this.enabled = options.enabled;
173
+ if (Number.isFinite(options.cooldownMs)) this.cooldownMs = options.cooldownMs;
174
+ if (Number.isFinite(options.factor)) this.factor = Math.min(0.9, Math.max(0.05, options.factor));
175
+ if (Number.isFinite(options.recoverAfter)) this.recoverAfter = Math.max(1, options.recoverAfter);
176
+ }
177
+
178
+ /**
179
+ * Current rate multiplier for `key` (1 when not cooling).
180
+ *
181
+ * @param {string} key
182
+ * @param {number} [now]
183
+ */
184
+ factorFor(key, now = Date.now()) {
185
+ const entry = this.#state.get(key);
186
+ if (entry === undefined) return 1;
187
+ if (entry.until > now) return entry.factor;
188
+ // Cooldown elapsed: stay scaled down until enough clean calls have passed.
189
+ return entry.clean >= this.recoverAfter ? 1 : entry.factor;
190
+ }
191
+
192
+ /**
193
+ * Record a provider-side `RATE_LIMIT` failure.
194
+ *
195
+ * @param {string} key
196
+ * @param {number} [now]
197
+ * @param {number} [retryAfterMs] provider hint, when known
198
+ * @returns {{ until: number, factor: number } | undefined}
199
+ */
200
+ recordFailure(key, now = Date.now(), retryAfterMs = undefined) {
201
+ if (!this.enabled) return undefined;
202
+ const hold = Number.isFinite(retryAfterMs) && retryAfterMs > 0
203
+ ? Math.max(this.cooldownMs, retryAfterMs)
204
+ : this.cooldownMs;
205
+ const entry = { until: now + hold, factor: this.factor, clean: 0 };
206
+ this.#state.set(key, entry);
207
+ return entry;
208
+ }
209
+
210
+ /**
211
+ * Record a call that completed without a provider-side 429, retiring the
212
+ * cooldown once enough of them have accumulated.
213
+ *
214
+ * @param {string} key
215
+ * @param {number} [now]
216
+ */
217
+ recordSuccess(key, now = Date.now()) {
218
+ const entry = this.#state.get(key);
219
+ if (entry === undefined) return;
220
+ if (entry.until > now) return; // still cooling: a success does not end it early
221
+ entry.clean += 1;
222
+ if (entry.clean >= this.recoverAfter) this.#state.delete(key);
223
+ }
224
+
225
+ /** True while `key` is inside its cooldown window. */
226
+ isCooling(key, now = Date.now()) {
227
+ const entry = this.#state.get(key);
228
+ return entry !== undefined && entry.until > now;
229
+ }
230
+
231
+ /** Milliseconds until the cooldown ends (0 when not cooling). */
232
+ remainingMs(key, now = Date.now()) {
233
+ const entry = this.#state.get(key);
234
+ return entry === undefined ? 0 : Math.max(0, entry.until - now);
235
+ }
236
+
237
+ /** Drop every cooldown (config reset / panel action). */
238
+ clear() {
239
+ this.#state.clear();
240
+ }
241
+
242
+ /** Drop the state of one model. */
243
+ forget(key) {
244
+ this.#state.delete(key);
245
+ }
246
+
247
+ /** JSON-safe view for the status channel. */
248
+ snapshot(now = Date.now()) {
249
+ const models = {};
250
+ for (const [key, entry] of this.#state) {
251
+ models[key] = {
252
+ cooling: entry.until > now,
253
+ remainingMs: Math.max(0, entry.until - now),
254
+ factor: entry.factor,
255
+ };
256
+ }
257
+ return models;
258
+ }
259
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Type declarations for the host entry point.
3
+ *
4
+ * The plugin ships no build step, so these are hand-written against the shapes
5
+ * `lib/index.js` actually uses. The `Config` export is a schemastery schema
6
+ * (validated at load time by Cordis); it is typed as `unknown` rather than
7
+ * pretending to model schemastery's builder.
8
+ */
9
+
10
+ /** Limits shared by the `defaults`, `providers` and `models` tiers. */
11
+ export interface RateLimitTier {
12
+ /** Max concurrent in-flight requests for this tier. */
13
+ maxConcurrent?: number;
14
+ /** Max requests per minute for this tier. */
15
+ maxRpm?: number;
16
+ /** Burst capacity (token-bucket only). */
17
+ burstSize?: number;
18
+ /** Tokens refilled per second (token-bucket only). */
19
+ refillRate?: number;
20
+ }
21
+
22
+ /** A `models` override. Keys may be exact (`provider/model`) or wildcarded (`provider/*`). */
23
+ export interface ModelOverride extends RateLimitTier {
24
+ /** Set `false` to exempt this model (or pattern) from limiting entirely. */
25
+ enabled?: boolean;
26
+ }
27
+
28
+ /** Adaptive back-off applied after the provider answers 429. */
29
+ export interface CooldownConfig {
30
+ /** Master switch for the 429 cooldown. */
31
+ enabled?: boolean;
32
+ /** How long the reduced rate lasts, in milliseconds. */
33
+ cooldownMs?: number;
34
+ /** Rate multiplier while cooling, in `(0, 1)`. Defaults to `0.5`. */
35
+ factor?: number;
36
+ /** Clean calls required before the full rate is restored. */
37
+ recoverAfter?: number;
38
+ }
39
+
40
+ /** Shape of this plugin's profile-entry config (all fields optional). */
41
+ export interface RateLimiterConfig {
42
+ /** Master switch for the limiter. */
43
+ enabled?: boolean;
44
+ /** Algorithm used for every model unless a tier says otherwise. */
45
+ strategy?: "token-bucket" | "sliding-window";
46
+ /** Global tier, applied when neither a provider nor a model tier matches. */
47
+ defaults?: RateLimitTier;
48
+ /** Per-provider tier, keyed by provider id (wildcards accepted). */
49
+ providers?: Record<string, RateLimitTier>;
50
+ /** Per-model tier, keyed by `provider/model` (wildcards accepted). */
51
+ models?: Record<string, ModelOverride>;
52
+ /** Adaptive back-off after provider-side throttling. */
53
+ cooldown?: CooldownConfig;
54
+ /** `queue` waits for capacity; `reject` fails the call immediately. */
55
+ onThrottled?: "queue" | "reject";
56
+ /** How long a queued call may wait before it is refused. */
57
+ maxQueueWaitMs?: number;
58
+ }
59
+
60
+ /**
61
+ * Structural subset of the Cordis `Context` that `apply` relies on.
62
+ *
63
+ * Declared locally so consumers do not need `@deepseek-ai/cordis` installed to
64
+ * type-check against this package.
65
+ */
66
+ export interface HostContext {
67
+ logger?: (scope: string) => {
68
+ info(...args: unknown[]): void;
69
+ warn(...args: unknown[]): void;
70
+ };
71
+ on(event: string, listener: (...args: any[]) => any): () => void;
72
+ effect(effect: () => (() => void) | void, label?: string): void;
73
+ inject(dependencies: string[], callback: (ctx: any) => void): void;
74
+ fiber?: unknown;
75
+ [key: string]: unknown;
76
+ }
77
+
78
+ /** Plugin name as registered in `cordis.patch.yml`. */
79
+ export declare const name: string;
80
+
81
+ /** Settings namespace / profile entry id. */
82
+ export declare const SETTINGS_NS: string;
83
+
84
+ /** schemastery schema for the profile entry's `config`. */
85
+ export declare const Config: unknown;
86
+
87
+ /**
88
+ * Cordis plugin entry point. Registers the `llm/stream` interceptor, the
89
+ * settings presentation opt-out and the status RPC channel.
90
+ */
91
+ export declare function apply(ctx: HostContext, config?: RateLimiterConfig): void;
package/lib/index.js CHANGED
@@ -22,6 +22,11 @@
22
22
  import { TokenBucketStrategy } from "./strategies/token-bucket.js";
23
23
  import { SlidingWindowStrategy } from "./strategies/sliding-window.js";
24
24
  import { Config } from "./types/config.js";
25
+ import {
26
+ CooldownTracker,
27
+ resolveModelConfig,
28
+ scaleConfig,
29
+ } from "./config-resolve.js";
25
30
  import { CHANNEL, createChannelRoute, createStatusChannel } from "./status-rpc.js";
26
31
 
27
32
  const name = "llm-multi-rate-limiter";
@@ -38,6 +43,10 @@ const SETTINGS_NS = "llm-multi-rate-limiter";
38
43
  const EVENT_RING_LIMIT = 64;
39
44
  /** Events carried by one snapshot (the panel shows these). */
40
45
  const SNAPSHOT_EVENT_COUNT = 8;
46
+ /** How often idle, unconfigured limiters are dropped from the map. */
47
+ const IDLE_SWEEP_MS = 60_000;
48
+ /** A limiter with no override and no activity for this long is released. */
49
+ const IDLE_LIMITER_MS = 10 * 60_000;
41
50
 
42
51
  /* ── Helpers ─────────────────────────────────────────────────────────── */
43
52
 
@@ -46,7 +55,9 @@ const FALLBACK = {
46
55
  enabled: true,
47
56
  strategy: "token-bucket",
48
57
  defaults: { maxConcurrent: 5, maxRpm: 60, burstSize: 10, refillRate: 1 },
58
+ providers: {},
49
59
  models: {},
60
+ cooldown: { enabled: true, cooldownMs: 30_000, factor: 0.5, recoverAfter: 3 },
50
61
  onThrottled: "queue",
51
62
  maxQueueWaitMs: 60_000,
52
63
  };
@@ -74,41 +85,21 @@ function read(ref, fallback) {
74
85
  return ref;
75
86
  }
76
87
 
77
- /**
78
- * Merge defaults + per-model overrides into a flat config object that both
79
- * strategy constructors understand.
80
- *
81
- * @param cfg - plain config object (`{ enabled, strategy, defaults, models, ... }`).
82
- * @param key - `"provider/model"` of the call being limited.
83
- */
84
- function resolveModelConfig(cfg, key) {
85
- const o = cfg.models?.[key] ?? {};
86
- const d = cfg.defaults ?? {};
87
- const maxRpm = o.maxRpm ?? d.maxRpm ?? 60;
88
- // When a model overrides maxRpm but not refillRate, derive refillRate from
89
- // the model's maxRpm — this is the most common case ("set maxRpm=3").
90
- // When model doesn't override maxRpm, use the defaults' refillRate as-is.
91
- const refillRate = o.refillRate ?? (o.maxRpm != null ? o.maxRpm / 60 : d.refillRate ?? (maxRpm / 60));
92
- return {
93
- maxConcurrent: o.maxConcurrent ?? d.maxConcurrent ?? 5,
94
- maxRpm,
95
- burstSize: o.burstSize ?? d.burstSize ?? 10,
96
- refillRate,
97
- windowMs: d.windowMs ?? 60_000,
98
- };
99
- }
100
-
101
88
  /**
102
89
  * Ensure a rate-limiting strategy instance exists (or is updated) for `key`.
90
+ *
91
+ * @param {Map<string, TokenBucketStrategy | SlidingWindowStrategy>} limiters
92
+ * @param {string} key
93
+ * @param {ReturnType<typeof resolveModelConfig>} merged resolved tier config
94
+ * @param {string} strategyName
103
95
  */
104
- function ensureLimiter(limiters, key, cfg) {
105
- const merged = resolveModelConfig(cfg, key);
96
+ function ensureLimiter(limiters, key, merged, strategyName) {
106
97
  const existing = limiters.get(key);
107
98
  if (existing) {
108
99
  existing.update(merged);
109
100
  return existing;
110
101
  }
111
- const strategy = cfg.strategy === "sliding-window"
102
+ const strategy = strategyName === "sliding-window"
112
103
  ? new SlidingWindowStrategy(merged)
113
104
  : new TokenBucketStrategy(merged);
114
105
  limiters.set(key, strategy);
@@ -117,21 +108,44 @@ function ensureLimiter(limiters, key, cfg) {
117
108
 
118
109
  /**
119
110
  * Build a terminal StreamChunk for rate-limiting failures.
120
- * Uses `kind: "aborted"` when the caller's AbortSignal fired, so the agent
121
- * loop treats it as a cancellation rather than a retryable error.
122
- * Otherwise uses `kind: "error"` with `RATE_LIMIT` code, which the
123
- * `dsh-llm-retry` plugin can catch and back off.
111
+ *
112
+ * The failure code matters: `dsh-llm-retry` only retries the codes its policy
113
+ * lists (by default `RATE_LIMIT`, `SERVER`, `TIMEOUT`, `TRANSPORT`,
114
+ * `EMPTY_RESPONSE`). A queue timeout is a LOCAL decision — retrying it just
115
+ * parks the caller in another queue — so it carries its own code, which no
116
+ * default policy retries. A provider-side 429 keeps `RATE_LIMIT`, because
117
+ * retrying THAT is exactly what the retry plugin is for.
118
+ *
119
+ * `kind: "aborted"` is used when the caller's AbortSignal fired, so the agent
120
+ * loop treats it as a cancellation rather than a failure.
124
121
  */
125
- function terminalChunk(modelKey, signal) {
122
+ function terminalChunk(modelKey, code, signal) {
126
123
  const aborted = signal?.aborted === true;
124
+ const failure = {
125
+ code,
126
+ message: aborted
127
+ ? `Rate limit: ${modelKey} (aborted while waiting)`
128
+ : `Rate limit: ${modelKey} was not granted a slot in time`,
129
+ };
127
130
  return {
128
131
  type: "finish",
129
- reason: aborted
130
- ? { kind: "aborted", failure: { code: "RATE_LIMIT", message: `Rate limit: ${modelKey} (aborted)` } }
131
- : { kind: "error", failure: { code: "RATE_LIMIT", message: `Rate limit exceeded for ${modelKey}` } },
132
+ reason: aborted ? { kind: "aborted", failure } : { kind: "error", failure },
132
133
  };
133
134
  }
134
135
 
136
+ /** True when a stream chunk reports that the PROVIDER throttled the call. */
137
+ function isProviderRateLimit(chunk) {
138
+ return chunk?.type === "finish"
139
+ && chunk.reason?.kind === "error"
140
+ && chunk.reason?.failure?.code === "RATE_LIMIT";
141
+ }
142
+
143
+ /** Optional `retryAfterMs` hint carried by a provider failure, when present. */
144
+ function retryAfterOf(chunk) {
145
+ const hint = chunk?.reason?.failure?.retryAfterMs;
146
+ return Number.isFinite(hint) && hint > 0 ? hint : undefined;
147
+ }
148
+
135
149
  /* ── Plugin entry ────────────────────────────────────────────────────── */
136
150
 
137
151
  /**
@@ -151,7 +165,9 @@ function apply(ctx, config) {
151
165
  enabled: read(config?.enabled, FALLBACK.enabled),
152
166
  strategy: read(config?.strategy, FALLBACK.strategy),
153
167
  defaults: read(config?.defaults, FALLBACK.defaults),
168
+ providers: read(config?.providers, FALLBACK.providers),
154
169
  models: read(config?.models, FALLBACK.models),
170
+ cooldown: read(config?.cooldown, FALLBACK.cooldown),
155
171
  onThrottled: read(config?.onThrottled, FALLBACK.onThrottled),
156
172
  maxQueueWaitMs: read(config?.maxQueueWaitMs, FALLBACK.maxQueueWaitMs),
157
173
  };
@@ -159,6 +175,8 @@ function apply(ctx, config) {
159
175
 
160
176
  /** @type {Map<string, TokenBucketStrategy | SlidingWindowStrategy>} */
161
177
  const limiters = new Map();
178
+ /** Adaptive back-off state after provider 429s. */
179
+ const cooldown = new CooldownTracker(FALLBACK.cooldown);
162
180
 
163
181
  // ── Live statistics (read by the status RPC channel) ───────
164
182
  // Counters are plain numbers so a snapshot is always JSON-safe.
@@ -168,6 +186,7 @@ function apply(ctx, config) {
168
186
  rejected: 0,
169
187
  timeouts: 0,
170
188
  aborted: 0,
189
+ throttledByProvider: 0,
171
190
  totalWaitMs: 0,
172
191
  };
173
192
  /** Bumped on every recorded event and on reset — the panel's change cursor. */
@@ -187,7 +206,15 @@ function apply(ctx, config) {
187
206
  const models = {};
188
207
  for (const [key, limiter] of limiters) {
189
208
  try {
190
- models[key] = limiter.getStatus();
209
+ const status = limiter.getStatus();
210
+ const factor = cooldown.factorFor(key);
211
+ models[key] = {
212
+ ...status,
213
+ // Effective limit after any 429 penalty — what the panel should show.
214
+ effectiveMaxRpm: factor < 1 ? Math.max(1, Math.round((status.maxRpm ?? 0) * factor)) : status.maxRpm,
215
+ cooling: cooldown.isCooling(key),
216
+ cooldownRemainingMs: cooldown.remainingMs(key),
217
+ };
191
218
  } catch {
192
219
  // A strategy that cannot report status must not break the whole panel.
193
220
  }
@@ -198,6 +225,10 @@ function apply(ctx, config) {
198
225
  enabled: cfg.enabled !== false,
199
226
  strategy: cfg.strategy,
200
227
  onThrottled: cfg.onThrottled,
228
+ cooldown: {
229
+ enabled: cfg.cooldown?.enabled !== false,
230
+ models: cooldown.snapshot(),
231
+ },
201
232
  totals: { ...totals },
202
233
  models,
203
234
  events: eventRing.slice(-SNAPSHOT_EVENT_COUNT),
@@ -208,9 +239,42 @@ function apply(ctx, config) {
208
239
  function resetTotals() {
209
240
  for (const key of Object.keys(totals)) totals[key] = 0;
210
241
  eventRing.length = 0;
242
+ cooldown.clear();
211
243
  rev += 1;
212
244
  }
213
245
 
246
+ /** Drop limiters that no tier configures and nothing has touched for a while. */
247
+ function sweepIdleLimiters() {
248
+ if (limiters.size === 0) return;
249
+ const cfg = readConfig();
250
+ const now = Date.now();
251
+ for (const [key, limiter] of limiters) {
252
+ if (now - (limiter.lastUsedAt ?? 0) < IDLE_LIMITER_MS) continue;
253
+ let status;
254
+ try {
255
+ status = limiter.getStatus();
256
+ } catch {
257
+ status = undefined;
258
+ }
259
+ // Never drop a limiter with work in flight or queued behind it.
260
+ if (status && ((status.concurrent ?? 0) > 0 || (status.reserved ?? 0) > 0 || (status.queued ?? 0) > 0)) continue;
261
+ // A model that still has an explicit override stays visible even when idle.
262
+ if (resolveModelConfig(cfg, key).source !== "defaults") continue;
263
+ try {
264
+ limiter.dispose();
265
+ } catch {
266
+ // Disposal is best-effort; dropping the map entry is what matters.
267
+ }
268
+ limiters.delete(key);
269
+ cooldown.forget(key);
270
+ rev += 1;
271
+ }
272
+ }
273
+
274
+ const sweepTimer = setInterval(sweepIdleLimiters, IDLE_SWEEP_MS);
275
+ // Node's unref keeps the timer from holding the process open on shutdown.
276
+ sweepTimer.unref?.();
277
+
214
278
  // ── Settings presentation (dynamic inject) ─────────────────
215
279
  // This plugin ships its own browser page, so it opts out of any
216
280
  // schema-generated page. Wrapped in ctx.inject so a host WITHOUT a settings
@@ -248,7 +312,7 @@ function apply(ctx, config) {
248
312
  // 'cannot get property "webServer" without inject'. Registering the
249
313
  // route on our own fiber (which injects webServer below) avoids that
250
314
  // while still using the framework's own 403/401 fence.
251
- // Still unfixed in 0.1.7-rc.1 (dsh-client-connection/lib/index.js:798
315
+ // Still unfixed in 0.2.1-rc.2 (dsh-client-connection/lib/index.js:1013
252
316
  // keeps `inject = ["credentials"]`), so the fallback still fires.
253
317
  ctx.inject(["connection"], (scopedCtx) => {
254
318
  const connection = scopedCtx.get("connection");
@@ -350,12 +414,20 @@ function apply(ctx, config) {
350
414
 
351
415
  try {
352
416
  const cfg = readConfig();
417
+ cooldown.configure(cfg.cooldown);
353
418
 
354
419
  if (!cfg.enabled || cfg.models?.[modelKey]?.enabled === false) {
355
420
  // Global switch off, or per-model opt-out: zero-touch pass-through.
356
421
  passThrough = true;
357
422
  } else {
358
- limiter = ensureLimiter(limiters, modelKey, cfg);
423
+ // Resolve the tier chain (model > provider > defaults) and apply any
424
+ // 429 penalty currently in force for this model.
425
+ const resolved = resolveModelConfig(cfg, modelKey);
426
+ const factor = cooldown.factorFor(modelKey);
427
+ const effective = factor < 1 ? scaleConfig(resolved, factor) : resolved;
428
+
429
+ limiter = ensureLimiter(limiters, modelKey, effective, cfg.strategy);
430
+ limiter.lastUsedAt = Date.now();
359
431
  totals.requests += 1;
360
432
 
361
433
  if (cfg.onThrottled === "reject") {
@@ -371,7 +443,8 @@ function apply(ctx, config) {
371
443
  totals.rejected += 1;
372
444
  recordEvent({ event: "rejected", model: modelKey });
373
445
  logger?.warn("model %C rejected — rate limit reached", modelKey);
374
- refused = terminalChunk(modelKey, options.signal);
446
+ // Local decision: not a retryable provider condition.
447
+ refused = terminalChunk(modelKey, "RATE_LIMIT_REJECTED", options.signal);
375
448
  }
376
449
  } else {
377
450
  // Queue mode — park until a slot opens.
@@ -396,7 +469,8 @@ function apply(ctx, config) {
396
469
  "model %C %s after %d ms",
397
470
  modelKey, aborted ? "aborted" : "queue timeout", permit.waitMs,
398
471
  );
399
- refused = terminalChunk(modelKey, options.signal);
472
+ // Own code: a queue timeout must not be retried into another queue.
473
+ refused = terminalChunk(modelKey, "RATE_LIMIT_QUEUE_TIMEOUT", options.signal);
400
474
  }
401
475
  }
402
476
  }
@@ -433,19 +507,43 @@ function apply(ctx, config) {
433
507
  // ── Phase 2: run the real LLM call ─────────────────────────
434
508
  // Queue mode does not take a concurrent slot inside acquire().
435
509
  if (!slotTaken) limiter.acquireSlot();
510
+ const activeLimiter = limiter;
436
511
  try {
437
- yield* next();
512
+ // Pass chunks straight through while watching for a provider 429: the
513
+ // stream stays streaming, and the cooldown learns from the failure.
514
+ for await (const chunk of next()) {
515
+ if (isProviderRateLimit(chunk)) {
516
+ const retryAfterMs = retryAfterOf(chunk);
517
+ const entry = cooldown.recordFailure(modelKey, Date.now(), retryAfterMs);
518
+ totals.throttledByProvider += 1;
519
+ recordEvent({
520
+ event: "throttled",
521
+ model: modelKey,
522
+ cooldownMs: entry === undefined ? 0 : Math.max(0, entry.until - Date.now()),
523
+ });
524
+ logger?.warn(
525
+ "provider throttled %C; slowing to %d%% for %d ms",
526
+ modelKey,
527
+ Math.round((entry?.factor ?? 1) * 100),
528
+ entry === undefined ? 0 : Math.max(0, entry.until - Date.now()),
529
+ );
530
+ }
531
+ yield chunk;
532
+ }
533
+ cooldown.recordSuccess(modelKey);
438
534
  } finally {
439
- limiter.releaseSlot();
535
+ activeLimiter.releaseSlot();
440
536
  recordEvent({ event: "completed", model: modelKey });
441
537
  }
442
538
  });
443
539
 
444
540
  // ── Teardown ──
445
541
  ctx.effect(() => () => {
542
+ clearInterval(sweepTimer);
446
543
  disposeLlmListener();
447
544
  for (const limiter of limiters.values()) limiter.dispose();
448
545
  limiters.clear();
546
+ cooldown.clear();
449
547
  }, "rate-limiter: dispose llm/stream listener");
450
548
 
451
549
  logger?.info("rate limiter active — llm/stream interceptor registered");