@elisym/sdk 0.13.0 → 0.15.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,426 @@
1
+ 'use strict';
2
+
3
+ // src/llm-health/constants.ts
4
+ var DEFAULT_HEALTH_TTL_MS = 10 * 60 * 1e3;
5
+ var DEFAULT_HEARTBEAT_INTERVAL_MS = 10 * 60 * 1e3;
6
+ var UNAVAILABLE_TOLERANCE = 3;
7
+ var DEFAULT_FREE_LLM_PER_CUSTOMER_WINDOW_MS = 60 * 60 * 1e3;
8
+ var DEFAULT_FREE_LLM_PER_CUSTOMER_MAX = 3;
9
+ var DEFAULT_FREE_LLM_GLOBAL_WINDOW_MS = 60 * 1e3;
10
+ var DEFAULT_FREE_LLM_GLOBAL_MAX = 30;
11
+ var DEFAULT_FREE_LLM_MAX_TRACKED_KEYS = 1e3;
12
+
13
+ // src/llm-health/types.ts
14
+ var LlmHealthError = class extends Error {
15
+ reason;
16
+ provider;
17
+ model;
18
+ constructor(reason, provider, model, detail) {
19
+ super(`LLM ${provider}/${model} ${reason}: ${detail}`);
20
+ this.name = "LlmHealthError";
21
+ this.reason = reason;
22
+ this.provider = provider;
23
+ this.model = model;
24
+ }
25
+ };
26
+
27
+ // src/llm-health/monitor.ts
28
+ function keyOf(provider, model) {
29
+ return `${provider}\0${model}`;
30
+ }
31
+ function reasonDetail(verification) {
32
+ if (verification.ok) {
33
+ return "ok";
34
+ }
35
+ if (verification.reason === "invalid") {
36
+ return `HTTP ${verification.status}: ${verification.body.slice(0, 200)}`;
37
+ }
38
+ if (verification.reason === "billing") {
39
+ const status = verification.status ?? 0;
40
+ const body = (verification.body ?? "").slice(0, 200);
41
+ return `HTTP ${status}: ${body}`;
42
+ }
43
+ return verification.error;
44
+ }
45
+ var LlmHealthMonitor = class {
46
+ entries = /* @__PURE__ */ new Map();
47
+ ttlMs;
48
+ unavailableTolerance;
49
+ now;
50
+ constructor(options = {}) {
51
+ this.ttlMs = options.ttlMs ?? DEFAULT_HEALTH_TTL_MS;
52
+ this.unavailableTolerance = options.unavailableTolerance ?? UNAVAILABLE_TOLERANCE;
53
+ this.now = options.now ?? Date.now;
54
+ }
55
+ /**
56
+ * Register a (provider, model) pair with its probe function. Idempotent
57
+ * on the (provider, model) key: re-registering replaces the verifyFn
58
+ * and resets state to `unknown`. Callers typically re-register only
59
+ * when the operator rotates the API key.
60
+ */
61
+ register(args) {
62
+ const key = keyOf(args.provider, args.model);
63
+ this.entries.set(key, {
64
+ provider: args.provider,
65
+ model: args.model,
66
+ verifyFn: args.verifyFn,
67
+ status: "unknown",
68
+ lastVerifiedAt: 0,
69
+ lastReason: void 0,
70
+ inFlight: null,
71
+ consecutiveFailures: 0
72
+ });
73
+ }
74
+ /**
75
+ * Seed the monitor with an already-known verification result, e.g.
76
+ * the one captured at startup. Skips an extra probe on the first
77
+ * `assertReady` call within the TTL window. No-op if the pair is not
78
+ * registered.
79
+ */
80
+ seed(provider, model, verification) {
81
+ const entry = this.entries.get(keyOf(provider, model));
82
+ if (!entry) {
83
+ return;
84
+ }
85
+ this.applyVerification(entry, verification);
86
+ }
87
+ /**
88
+ * Main gate before doing LLM work. Throws `LlmHealthError` on terminal
89
+ * states (`invalid`, `billing`, or `unavailable` past tolerance);
90
+ * resolves silently on `healthy` (cache hit or fresh probe) or
91
+ * tolerated `unavailable`.
92
+ *
93
+ * Concurrent `assertReady` calls for the same pair are deduplicated:
94
+ * the second caller awaits the first probe rather than launching a
95
+ * parallel one.
96
+ */
97
+ async assertReady(provider, model) {
98
+ const entry = this.entries.get(keyOf(provider, model));
99
+ if (!entry) {
100
+ throw new LlmHealthError("invalid", provider, model, "pair not registered");
101
+ }
102
+ const verification = await this.probeIfNeeded(entry);
103
+ if (verification.ok) {
104
+ return;
105
+ }
106
+ if (verification.reason === "invalid" || verification.reason === "billing") {
107
+ throw new LlmHealthError(verification.reason, provider, model, reasonDetail(verification));
108
+ }
109
+ if (entry.consecutiveFailures >= this.unavailableTolerance) {
110
+ throw new LlmHealthError("unavailable", provider, model, reasonDetail(verification));
111
+ }
112
+ }
113
+ /**
114
+ * Force the next `assertReady` for this pair to re-probe regardless of
115
+ * TTL. Used when a real LLM call surfaces 401/402 mid-job - the cached
116
+ * `healthy` is stale and we want to catch the next request.
117
+ */
118
+ markFailureFromJob(provider, model) {
119
+ const entry = this.entries.get(keyOf(provider, model));
120
+ if (!entry) {
121
+ return;
122
+ }
123
+ entry.lastVerifiedAt = 0;
124
+ }
125
+ /**
126
+ * Refresh every registered pair concurrently. Heartbeat hook. Errors
127
+ * thrown by `verifyFn` are caught and recorded as `unavailable`.
128
+ */
129
+ async refreshAll() {
130
+ const probes = [];
131
+ for (const entry of this.entries.values()) {
132
+ probes.push(this.probe(entry).then(() => void 0));
133
+ }
134
+ await Promise.all(probes);
135
+ return this.snapshot();
136
+ }
137
+ /** Read-only view, primarily for logs and tests. */
138
+ snapshot() {
139
+ const out = [];
140
+ for (const entry of this.entries.values()) {
141
+ out.push({
142
+ provider: entry.provider,
143
+ model: entry.model,
144
+ status: entry.status,
145
+ lastVerifiedAt: entry.lastVerifiedAt,
146
+ lastReason: entry.lastReason,
147
+ consecutiveFailures: entry.consecutiveFailures
148
+ });
149
+ }
150
+ return out;
151
+ }
152
+ probeIfNeeded(entry) {
153
+ if (entry.inFlight) {
154
+ return entry.inFlight;
155
+ }
156
+ const fresh = this.now() - entry.lastVerifiedAt < this.ttlMs;
157
+ if (fresh && entry.status === "healthy") {
158
+ return Promise.resolve({ ok: true });
159
+ }
160
+ return this.probe(entry);
161
+ }
162
+ probe(entry) {
163
+ if (entry.inFlight) {
164
+ return entry.inFlight;
165
+ }
166
+ const promise = (async () => {
167
+ try {
168
+ return await entry.verifyFn();
169
+ } catch (error) {
170
+ const message = error instanceof Error ? error.message : String(error);
171
+ return { ok: false, reason: "unavailable", error: message };
172
+ }
173
+ })().then((verification) => {
174
+ this.applyVerification(entry, verification);
175
+ entry.inFlight = null;
176
+ return verification;
177
+ });
178
+ entry.inFlight = promise;
179
+ return promise;
180
+ }
181
+ applyVerification(entry, verification) {
182
+ entry.lastVerifiedAt = this.now();
183
+ if (verification.ok) {
184
+ entry.status = "healthy";
185
+ entry.lastReason = void 0;
186
+ entry.consecutiveFailures = 0;
187
+ return;
188
+ }
189
+ entry.lastReason = reasonDetail(verification);
190
+ if (verification.reason === "unavailable") {
191
+ entry.status = "unavailable";
192
+ entry.consecutiveFailures += 1;
193
+ return;
194
+ }
195
+ entry.status = verification.reason;
196
+ entry.consecutiveFailures = 0;
197
+ }
198
+ };
199
+
200
+ // src/llm-health/heartbeat.ts
201
+ var NOOP_LOG = (_msg) => {
202
+ };
203
+ function isHealthyState(status) {
204
+ return status === "healthy" || status === "unknown";
205
+ }
206
+ function describeTransition(prev, next) {
207
+ const wasHealthy = isHealthyState(prev);
208
+ const nowHealthy = isHealthyState(next.status);
209
+ if (wasHealthy === nowHealthy) {
210
+ return void 0;
211
+ }
212
+ if (wasHealthy && !nowHealthy) {
213
+ const reason = next.lastReason ?? next.status;
214
+ return `! LLM provider ${next.provider} model ${next.model} became unhealthy: ${next.status} (${reason}). Refusing LLM jobs until recovered.`;
215
+ }
216
+ return `* LLM provider ${next.provider} model ${next.model} recovered.`;
217
+ }
218
+ function startLlmHeartbeat(options) {
219
+ const intervalMs = options.intervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
220
+ const log = options.log ?? NOOP_LOG;
221
+ let lastStatusByPair = /* @__PURE__ */ new Map();
222
+ for (const entry of options.monitor.snapshot()) {
223
+ lastStatusByPair.set(`${entry.provider}|${entry.model}`, entry.status);
224
+ }
225
+ let stopped = false;
226
+ const tick = async () => {
227
+ if (stopped) {
228
+ return;
229
+ }
230
+ let snapshot;
231
+ try {
232
+ snapshot = await options.monitor.refreshAll();
233
+ } catch (error) {
234
+ const message = error instanceof Error ? error.message : String(error);
235
+ log(`! LLM heartbeat refreshAll failed: ${message}`);
236
+ return;
237
+ }
238
+ const next = /* @__PURE__ */ new Map();
239
+ for (const entry of snapshot) {
240
+ const key = `${entry.provider}|${entry.model}`;
241
+ const prev = lastStatusByPair.get(key) ?? "unknown";
242
+ const transitionMessage = describeTransition(prev, entry);
243
+ if (transitionMessage) {
244
+ log(transitionMessage);
245
+ }
246
+ next.set(key, entry.status);
247
+ }
248
+ lastStatusByPair = next;
249
+ };
250
+ const handle = setInterval(() => {
251
+ void tick();
252
+ }, intervalMs);
253
+ return {
254
+ stop: () => {
255
+ if (stopped) {
256
+ return;
257
+ }
258
+ stopped = true;
259
+ clearInterval(handle);
260
+ }
261
+ };
262
+ }
263
+
264
+ // src/primitives/rateLimiter.ts
265
+ function createSlidingWindowLimiter(options) {
266
+ const { windowMs, maxPerWindow, maxKeys } = options;
267
+ if (windowMs <= 0) {
268
+ throw new RangeError("windowMs must be > 0");
269
+ }
270
+ if (maxPerWindow <= 0) {
271
+ throw new RangeError("maxPerWindow must be > 0");
272
+ }
273
+ if (maxKeys <= 0) {
274
+ throw new RangeError("maxKeys must be > 0");
275
+ }
276
+ const entries = /* @__PURE__ */ new Map();
277
+ function evictIfNeeded() {
278
+ while (entries.size > maxKeys) {
279
+ const oldestKey = entries.keys().next().value;
280
+ if (oldestKey === void 0) {
281
+ return;
282
+ }
283
+ entries.delete(oldestKey);
284
+ }
285
+ }
286
+ return {
287
+ peek(key, now = Date.now()) {
288
+ const entry = entries.get(key);
289
+ if (!entry) {
290
+ return { allowed: true, resetAt: now + windowMs, count: 0 };
291
+ }
292
+ const cutoff = now - windowMs;
293
+ const fresh = entry.hits.filter((timestamp) => timestamp > cutoff);
294
+ return {
295
+ allowed: fresh.length < maxPerWindow,
296
+ resetAt: (fresh[0] ?? now) + windowMs,
297
+ count: fresh.length
298
+ };
299
+ },
300
+ check(key, now = Date.now()) {
301
+ const entry = entries.get(key) ?? { hits: [] };
302
+ const cutoff = now - windowMs;
303
+ const fresh = entry.hits.filter((timestamp) => timestamp > cutoff);
304
+ if (fresh.length >= maxPerWindow) {
305
+ entries.delete(key);
306
+ entries.set(key, { hits: fresh });
307
+ return {
308
+ allowed: false,
309
+ resetAt: (fresh[0] ?? now) + windowMs,
310
+ count: fresh.length
311
+ };
312
+ }
313
+ fresh.push(now);
314
+ entries.delete(key);
315
+ entries.set(key, { hits: fresh });
316
+ evictIfNeeded();
317
+ return {
318
+ allowed: true,
319
+ resetAt: (fresh[0] ?? now) + windowMs,
320
+ count: fresh.length
321
+ };
322
+ },
323
+ prune(now = Date.now()) {
324
+ const cutoff = now - windowMs;
325
+ for (const [key, entry] of entries) {
326
+ const fresh = entry.hits.filter((timestamp) => timestamp > cutoff);
327
+ if (fresh.length === 0) {
328
+ entries.delete(key);
329
+ } else if (fresh.length !== entry.hits.length) {
330
+ entry.hits = fresh;
331
+ }
332
+ }
333
+ },
334
+ size() {
335
+ return entries.size;
336
+ },
337
+ reset() {
338
+ entries.clear();
339
+ }
340
+ };
341
+ }
342
+
343
+ // src/llm-health/free-llm-rate-limiter.ts
344
+ var FREE_LLM_GLOBAL_KEY = "__free_llm_global__";
345
+ function resolvePerSkillRateLimit(skillRateLimit) {
346
+ if (!skillRateLimit) {
347
+ return void 0;
348
+ }
349
+ if (skillRateLimit.maxPerWindow <= 0 || skillRateLimit.perWindowMs <= 0) {
350
+ return void 0;
351
+ }
352
+ return skillRateLimit;
353
+ }
354
+ function createFreeLlmLimiterSet(options = {}) {
355
+ const defaultPerCustomer = options.defaultPerCustomer ?? {
356
+ perWindowMs: DEFAULT_FREE_LLM_PER_CUSTOMER_WINDOW_MS,
357
+ maxPerWindow: DEFAULT_FREE_LLM_PER_CUSTOMER_MAX
358
+ };
359
+ const global = options.global ?? {
360
+ perWindowMs: DEFAULT_FREE_LLM_GLOBAL_WINDOW_MS,
361
+ maxPerWindow: DEFAULT_FREE_LLM_GLOBAL_MAX
362
+ };
363
+ const maxKeys = options.maxKeys ?? DEFAULT_FREE_LLM_MAX_TRACKED_KEYS;
364
+ const globalLimiter = createSlidingWindowLimiter({
365
+ windowMs: global.perWindowMs,
366
+ maxPerWindow: global.maxPerWindow,
367
+ maxKeys: 1
368
+ });
369
+ const defaultLimiter = createSlidingWindowLimiter({
370
+ windowMs: defaultPerCustomer.perWindowMs,
371
+ maxPerWindow: defaultPerCustomer.maxPerWindow,
372
+ maxKeys
373
+ });
374
+ const perSkillLimiters = /* @__PURE__ */ new Map();
375
+ function getPerCustomerLimiter(skillName, override) {
376
+ const effective = resolvePerSkillRateLimit(override);
377
+ if (!effective) {
378
+ return defaultLimiter;
379
+ }
380
+ const cached = perSkillLimiters.get(skillName);
381
+ if (cached) {
382
+ return cached;
383
+ }
384
+ const limiter = createSlidingWindowLimiter({
385
+ windowMs: effective.perWindowMs,
386
+ maxPerWindow: effective.maxPerWindow,
387
+ maxKeys
388
+ });
389
+ perSkillLimiters.set(skillName, limiter);
390
+ return limiter;
391
+ }
392
+ function prunePerCustomer() {
393
+ defaultLimiter.prune();
394
+ for (const limiter of perSkillLimiters.values()) {
395
+ limiter.prune();
396
+ }
397
+ }
398
+ return {
399
+ globalLimiter,
400
+ getPerCustomerLimiter,
401
+ prunePerCustomer,
402
+ defaultPerCustomer,
403
+ global
404
+ };
405
+ }
406
+ function freeLlmCustomerKey(customerId, skillName) {
407
+ return `${customerId}|${skillName}`;
408
+ }
409
+
410
+ exports.DEFAULT_FREE_LLM_GLOBAL_MAX = DEFAULT_FREE_LLM_GLOBAL_MAX;
411
+ exports.DEFAULT_FREE_LLM_GLOBAL_WINDOW_MS = DEFAULT_FREE_LLM_GLOBAL_WINDOW_MS;
412
+ exports.DEFAULT_FREE_LLM_MAX_TRACKED_KEYS = DEFAULT_FREE_LLM_MAX_TRACKED_KEYS;
413
+ exports.DEFAULT_FREE_LLM_PER_CUSTOMER_MAX = DEFAULT_FREE_LLM_PER_CUSTOMER_MAX;
414
+ exports.DEFAULT_FREE_LLM_PER_CUSTOMER_WINDOW_MS = DEFAULT_FREE_LLM_PER_CUSTOMER_WINDOW_MS;
415
+ exports.DEFAULT_HEALTH_TTL_MS = DEFAULT_HEALTH_TTL_MS;
416
+ exports.DEFAULT_HEARTBEAT_INTERVAL_MS = DEFAULT_HEARTBEAT_INTERVAL_MS;
417
+ exports.FREE_LLM_GLOBAL_KEY = FREE_LLM_GLOBAL_KEY;
418
+ exports.LlmHealthError = LlmHealthError;
419
+ exports.LlmHealthMonitor = LlmHealthMonitor;
420
+ exports.UNAVAILABLE_TOLERANCE = UNAVAILABLE_TOLERANCE;
421
+ exports.createFreeLlmLimiterSet = createFreeLlmLimiterSet;
422
+ exports.freeLlmCustomerKey = freeLlmCustomerKey;
423
+ exports.resolvePerSkillRateLimit = resolvePerSkillRateLimit;
424
+ exports.startLlmHeartbeat = startLlmHeartbeat;
425
+ //# sourceMappingURL=llm-health.cjs.map
426
+ //# sourceMappingURL=llm-health.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/llm-health/constants.ts","../src/llm-health/types.ts","../src/llm-health/monitor.ts","../src/llm-health/heartbeat.ts","../src/primitives/rateLimiter.ts","../src/llm-health/free-llm-rate-limiter.ts"],"names":[],"mappings":"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","file":"llm-health.cjs","sourcesContent":["/**\n * LLM health monitor and heartbeat tunable defaults. CLI and plugin consumers\n * can override via `process.env.ELISYM_LLM_HEALTH_TTL_MS` and\n * `ELISYM_LLM_HEARTBEAT_INTERVAL_MS` and pass the resolved values into\n * the monitor/heartbeat options.\n */\n\nexport const DEFAULT_HEALTH_TTL_MS = 10 * 60 * 1000;\nexport const DEFAULT_HEARTBEAT_INTERVAL_MS = 10 * 60 * 1000;\n\n/**\n * Number of consecutive `unavailable` results tolerated before\n * `assertReady` starts throwing. The first `unavailable - 1` are treated\n * as transient blips so a brief network hiccup does not block jobs.\n */\nexport const UNAVAILABLE_TOLERANCE = 3;\n\n/**\n * Free-LLM rate-limit defaults. Applied when a SKILL.md with\n * `mode: 'llm'` and `price: 0` does not declare its own `rate_limit`.\n */\nexport const DEFAULT_FREE_LLM_PER_CUSTOMER_WINDOW_MS = 60 * 60 * 1000;\nexport const DEFAULT_FREE_LLM_PER_CUSTOMER_MAX = 3;\n\nexport const DEFAULT_FREE_LLM_GLOBAL_WINDOW_MS = 60 * 1000;\nexport const DEFAULT_FREE_LLM_GLOBAL_MAX = 30;\n\nexport const DEFAULT_FREE_LLM_MAX_TRACKED_KEYS = 1000;\n","/**\n * Shared types for the LLM health monitor. Provider-agnostic: the\n * actual HTTP probe lives in CLI/plugin and is supplied via dependency\n * injection (`verifyFn`).\n */\n\nexport type LlmHealthStatus = 'unknown' | 'healthy' | 'invalid' | 'billing' | 'unavailable';\n\n/**\n * Result of probing an API key with a specific model. Discriminated on\n * `ok`, then on `reason` for failures. Adding a new failure reason is a\n * breaking change for exhaustive switches; update consumers in lockstep.\n *\n * - `invalid`: HTTP 401/403 - key rejected outright.\n * - `billing`: HTTP 402, or 400 with credit/billing/insufficient marker,\n * or OpenAI's 429 with `insufficient_quota`. Operator out of credits.\n * - `unavailable`: transient (HTTP 429 without quota marker, 5xx, network\n * error). May resolve on retry.\n */\nexport type LlmKeyVerification =\n | { ok: true }\n | { ok: false; reason: 'invalid'; status: number; body: string }\n | { ok: false; reason: 'billing'; status?: number; body?: string }\n | { ok: false; reason: 'unavailable'; error: string };\n\nexport type LlmHealthErrorReason = 'invalid' | 'billing' | 'unavailable';\n\n/**\n * Thrown by `LlmHealthMonitor.assertReady` when the gate refuses a job.\n * Carries the operator-facing reason so callers can log it; customer-facing\n * messages should be sanitized at the call site (see `runtime.ts` preflight).\n */\nexport class LlmHealthError extends Error {\n readonly reason: LlmHealthErrorReason;\n readonly provider: string;\n readonly model: string;\n\n constructor(reason: LlmHealthErrorReason, provider: string, model: string, detail: string) {\n super(`LLM ${provider}/${model} ${reason}: ${detail}`);\n this.name = 'LlmHealthError';\n this.reason = reason;\n this.provider = provider;\n this.model = model;\n }\n}\n\n/**\n * Per-skill rate-limit declaration. Snake-case in SKILL.md frontmatter,\n * camelCase here. Applies to any skill mode but the framework adds a\n * default cap only for free LLM skills.\n */\nexport interface SkillRateLimit {\n perWindowMs: number;\n maxPerWindow: number;\n}\n\n/** Read-only health snapshot for logs/tests. */\nexport interface LlmHealthSnapshotEntry {\n provider: string;\n model: string;\n status: LlmHealthStatus;\n lastVerifiedAt: number;\n lastReason: string | undefined;\n consecutiveFailures: number;\n}\n","/**\n * Provider-agnostic health monitor for LLM API keys. State machine per\n * (provider, model) pair: caches the last verification result for\n * `ttlMs`, deduplicates concurrent probes via an in-flight promise, and\n * tolerates a bounded number of consecutive `unavailable` results\n * before `assertReady` starts throwing.\n *\n * The monitor itself never touches the network. Each pair must be\n * registered with a `verifyFn` lambda that performs the actual probe;\n * callers (CLI/plugin) supply provider-specific HTTP from their layer.\n */\n\nimport {\n DEFAULT_HEALTH_TTL_MS,\n UNAVAILABLE_TOLERANCE as DEFAULT_UNAVAILABLE_TOLERANCE,\n} from './constants';\nimport {\n LlmHealthError,\n type LlmHealthSnapshotEntry,\n type LlmHealthStatus,\n type LlmKeyVerification,\n} from './types';\n\nexport type LlmKeyVerifyFn = (signal?: AbortSignal) => Promise<LlmKeyVerification>;\n\nexport interface LlmHealthMonitorOptions {\n /** Time after which a cached `healthy` result is re-probed. Default 10 min. */\n ttlMs?: number;\n /** Number of consecutive unavailable results tolerated. Default 3. */\n unavailableTolerance?: number;\n /** Optional clock injection for tests. */\n now?: () => number;\n}\n\nexport interface RegisterArgs {\n provider: string;\n model: string;\n verifyFn: LlmKeyVerifyFn;\n}\n\ninterface Entry {\n provider: string;\n model: string;\n verifyFn: LlmKeyVerifyFn;\n status: LlmHealthStatus;\n lastVerifiedAt: number;\n lastReason: string | undefined;\n inFlight: Promise<LlmKeyVerification> | null;\n consecutiveFailures: number;\n}\n\nfunction keyOf(provider: string, model: string): string {\n return `${provider}\u0000${model}`;\n}\n\nfunction reasonDetail(verification: LlmKeyVerification): string {\n if (verification.ok) {\n return 'ok';\n }\n if (verification.reason === 'invalid') {\n return `HTTP ${verification.status}: ${verification.body.slice(0, 200)}`;\n }\n if (verification.reason === 'billing') {\n const status = verification.status ?? 0;\n const body = (verification.body ?? '').slice(0, 200);\n return `HTTP ${status}: ${body}`;\n }\n return verification.error;\n}\n\nexport class LlmHealthMonitor {\n private readonly entries = new Map<string, Entry>();\n private readonly ttlMs: number;\n private readonly unavailableTolerance: number;\n private readonly now: () => number;\n\n constructor(options: LlmHealthMonitorOptions = {}) {\n this.ttlMs = options.ttlMs ?? DEFAULT_HEALTH_TTL_MS;\n this.unavailableTolerance = options.unavailableTolerance ?? DEFAULT_UNAVAILABLE_TOLERANCE;\n this.now = options.now ?? Date.now;\n }\n\n /**\n * Register a (provider, model) pair with its probe function. Idempotent\n * on the (provider, model) key: re-registering replaces the verifyFn\n * and resets state to `unknown`. Callers typically re-register only\n * when the operator rotates the API key.\n */\n register(args: RegisterArgs): void {\n const key = keyOf(args.provider, args.model);\n this.entries.set(key, {\n provider: args.provider,\n model: args.model,\n verifyFn: args.verifyFn,\n status: 'unknown',\n lastVerifiedAt: 0,\n lastReason: undefined,\n inFlight: null,\n consecutiveFailures: 0,\n });\n }\n\n /**\n * Seed the monitor with an already-known verification result, e.g.\n * the one captured at startup. Skips an extra probe on the first\n * `assertReady` call within the TTL window. No-op if the pair is not\n * registered.\n */\n seed(provider: string, model: string, verification: LlmKeyVerification): void {\n const entry = this.entries.get(keyOf(provider, model));\n if (!entry) {\n return;\n }\n this.applyVerification(entry, verification);\n }\n\n /**\n * Main gate before doing LLM work. Throws `LlmHealthError` on terminal\n * states (`invalid`, `billing`, or `unavailable` past tolerance);\n * resolves silently on `healthy` (cache hit or fresh probe) or\n * tolerated `unavailable`.\n *\n * Concurrent `assertReady` calls for the same pair are deduplicated:\n * the second caller awaits the first probe rather than launching a\n * parallel one.\n */\n async assertReady(provider: string, model: string): Promise<void> {\n const entry = this.entries.get(keyOf(provider, model));\n if (!entry) {\n throw new LlmHealthError('invalid', provider, model, 'pair not registered');\n }\n\n const verification = await this.probeIfNeeded(entry);\n\n if (verification.ok) {\n return;\n }\n if (verification.reason === 'invalid' || verification.reason === 'billing') {\n throw new LlmHealthError(verification.reason, provider, model, reasonDetail(verification));\n }\n if (entry.consecutiveFailures >= this.unavailableTolerance) {\n throw new LlmHealthError('unavailable', provider, model, reasonDetail(verification));\n }\n // Tolerated unavailable: caller proceeds, real LLM call will surface\n // any transient issue with its own error path.\n }\n\n /**\n * Force the next `assertReady` for this pair to re-probe regardless of\n * TTL. Used when a real LLM call surfaces 401/402 mid-job - the cached\n * `healthy` is stale and we want to catch the next request.\n */\n markFailureFromJob(provider: string, model: string): void {\n const entry = this.entries.get(keyOf(provider, model));\n if (!entry) {\n return;\n }\n entry.lastVerifiedAt = 0;\n }\n\n /**\n * Refresh every registered pair concurrently. Heartbeat hook. Errors\n * thrown by `verifyFn` are caught and recorded as `unavailable`.\n */\n async refreshAll(): Promise<readonly LlmHealthSnapshotEntry[]> {\n const probes: Array<Promise<void>> = [];\n for (const entry of this.entries.values()) {\n probes.push(this.probe(entry).then(() => undefined));\n }\n await Promise.all(probes);\n return this.snapshot();\n }\n\n /** Read-only view, primarily for logs and tests. */\n snapshot(): readonly LlmHealthSnapshotEntry[] {\n const out: LlmHealthSnapshotEntry[] = [];\n for (const entry of this.entries.values()) {\n out.push({\n provider: entry.provider,\n model: entry.model,\n status: entry.status,\n lastVerifiedAt: entry.lastVerifiedAt,\n lastReason: entry.lastReason,\n consecutiveFailures: entry.consecutiveFailures,\n });\n }\n return out;\n }\n\n private probeIfNeeded(entry: Entry): Promise<LlmKeyVerification> {\n if (entry.inFlight) {\n return entry.inFlight;\n }\n const fresh = this.now() - entry.lastVerifiedAt < this.ttlMs;\n if (fresh && entry.status === 'healthy') {\n return Promise.resolve({ ok: true });\n }\n return this.probe(entry);\n }\n\n private probe(entry: Entry): Promise<LlmKeyVerification> {\n if (entry.inFlight) {\n return entry.inFlight;\n }\n const promise = (async (): Promise<LlmKeyVerification> => {\n try {\n return await entry.verifyFn();\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n return { ok: false, reason: 'unavailable', error: message };\n }\n })().then((verification) => {\n this.applyVerification(entry, verification);\n entry.inFlight = null;\n return verification;\n });\n entry.inFlight = promise;\n return promise;\n }\n\n private applyVerification(entry: Entry, verification: LlmKeyVerification): void {\n entry.lastVerifiedAt = this.now();\n if (verification.ok) {\n entry.status = 'healthy';\n entry.lastReason = undefined;\n entry.consecutiveFailures = 0;\n return;\n }\n entry.lastReason = reasonDetail(verification);\n if (verification.reason === 'unavailable') {\n entry.status = 'unavailable';\n entry.consecutiveFailures += 1;\n return;\n }\n entry.status = verification.reason;\n entry.consecutiveFailures = 0;\n }\n}\n","/**\n * Periodic LLM health probe. Stops on demand. Logs status transitions\n * (healthy <-> unhealthy) but does not log every successful tick to keep\n * the operator log quiet.\n *\n * The heartbeat pure-delegates to `monitor.refreshAll()`; both timing and\n * logging policy live here so the monitor stays a pure state-machine.\n */\n\nimport { DEFAULT_HEARTBEAT_INTERVAL_MS } from './constants';\nimport type { LlmHealthMonitor } from './monitor';\nimport type { LlmHealthSnapshotEntry, LlmHealthStatus } from './types';\n\nexport interface HeartbeatHandle {\n stop(): void;\n}\n\nexport interface StartLlmHeartbeatOptions {\n monitor: LlmHealthMonitor;\n /** Defaults to 10 minutes. */\n intervalMs?: number;\n /** Operator log sink. Defaults to no-op (silent). */\n log?: (msg: string) => void;\n}\n\ntype IntervalHandle = ReturnType<typeof setInterval>;\n\nconst NOOP_LOG = (_msg: string): void => {};\n\nfunction isHealthyState(status: LlmHealthStatus): boolean {\n return status === 'healthy' || status === 'unknown';\n}\n\nfunction describeTransition(\n prev: LlmHealthStatus,\n next: LlmHealthSnapshotEntry,\n): string | undefined {\n const wasHealthy = isHealthyState(prev);\n const nowHealthy = isHealthyState(next.status);\n if (wasHealthy === nowHealthy) {\n return undefined;\n }\n if (wasHealthy && !nowHealthy) {\n const reason = next.lastReason ?? next.status;\n return `! LLM provider ${next.provider} model ${next.model} became unhealthy: ${next.status} (${reason}). Refusing LLM jobs until recovered.`;\n }\n return `* LLM provider ${next.provider} model ${next.model} recovered.`;\n}\n\nexport function startLlmHeartbeat(options: StartLlmHeartbeatOptions): HeartbeatHandle {\n const intervalMs = options.intervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;\n const log = options.log ?? NOOP_LOG;\n\n let lastStatusByPair = new Map<string, LlmHealthStatus>();\n for (const entry of options.monitor.snapshot()) {\n lastStatusByPair.set(`${entry.provider}|${entry.model}`, entry.status);\n }\n\n let stopped = false;\n\n const tick = async (): Promise<void> => {\n if (stopped) {\n return;\n }\n let snapshot: readonly LlmHealthSnapshotEntry[];\n try {\n snapshot = await options.monitor.refreshAll();\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n log(`! LLM heartbeat refreshAll failed: ${message}`);\n return;\n }\n const next = new Map<string, LlmHealthStatus>();\n for (const entry of snapshot) {\n const key = `${entry.provider}|${entry.model}`;\n const prev = lastStatusByPair.get(key) ?? 'unknown';\n const transitionMessage = describeTransition(prev, entry);\n if (transitionMessage) {\n log(transitionMessage);\n }\n next.set(key, entry.status);\n }\n lastStatusByPair = next;\n };\n\n const handle: IntervalHandle = setInterval(() => {\n void tick();\n }, intervalMs);\n\n return {\n stop: () => {\n if (stopped) {\n return;\n }\n stopped = true;\n clearInterval(handle);\n },\n };\n}\n","/**\n * Sliding-window rate limiter keyed by an arbitrary string (typically a\n * customer pubkey). Each key gets at most `maxPerWindow` requests inside a\n * rolling `windowMs`. Stale timestamps are pruned lazily on every `check`.\n * When the tracked-key set grows past `maxKeys`, the least-recently-used\n * key is evicted so an attacker cannot exhaust memory by cycling keys.\n *\n * Thread-safety: not required. Designed for single-threaded JS consumers\n * (Node/Bun event loops, browser main thread). No timers - pruning happens\n * inside `check` and `prune`.\n */\n\nexport interface SlidingWindowLimiterOptions {\n /** Rolling window width, in ms. */\n windowMs: number;\n /** Max hits allowed per key inside the window. */\n maxPerWindow: number;\n /** Cap on total tracked keys. LRU-evicted past this cap. */\n maxKeys: number;\n}\n\nexport interface RateLimitDecision {\n allowed: boolean;\n /** Wall-clock timestamp (ms) when the limit window will reset for this key. */\n resetAt: number;\n /** Number of hits inside the current window after this call (or the attempted hit if denied). */\n count: number;\n}\n\nexport interface SlidingWindowLimiter {\n /** Record a hit against `key`; return whether it was allowed. */\n check(key: string, now?: number): RateLimitDecision;\n /**\n * Inspect the current state for `key` without recording a hit. Useful\n * when a single request must clear multiple limiters and callers want\n * to avoid double-counting against one limiter after another denies.\n */\n peek(key: string, now?: number): RateLimitDecision;\n /** Drop entries whose windows have fully elapsed. Bounded memory hygiene. */\n prune(now?: number): void;\n /** Current number of tracked keys. */\n size(): number;\n /** Clear all state. */\n reset(): void;\n}\n\ninterface Entry {\n /** Sliding-window timestamps in ms. Sorted ascending. */\n hits: number[];\n}\n\nexport function createSlidingWindowLimiter(\n options: SlidingWindowLimiterOptions,\n): SlidingWindowLimiter {\n const { windowMs, maxPerWindow, maxKeys } = options;\n if (windowMs <= 0) {\n throw new RangeError('windowMs must be > 0');\n }\n if (maxPerWindow <= 0) {\n throw new RangeError('maxPerWindow must be > 0');\n }\n if (maxKeys <= 0) {\n throw new RangeError('maxKeys must be > 0');\n }\n\n // LRU is implemented via Map's insertion-order: every check refreshes\n // the entry by deleting and re-setting it, moving it to the tail.\n const entries = new Map<string, Entry>();\n\n function evictIfNeeded(): void {\n while (entries.size > maxKeys) {\n const oldestKey = entries.keys().next().value as string | undefined;\n if (oldestKey === undefined) {\n return;\n }\n entries.delete(oldestKey);\n }\n }\n\n return {\n peek(key, now = Date.now()): RateLimitDecision {\n const entry = entries.get(key);\n if (!entry) {\n return { allowed: true, resetAt: now + windowMs, count: 0 };\n }\n const cutoff = now - windowMs;\n const fresh = entry.hits.filter((timestamp) => timestamp > cutoff);\n return {\n allowed: fresh.length < maxPerWindow,\n resetAt: (fresh[0] ?? now) + windowMs,\n count: fresh.length,\n };\n },\n check(key, now = Date.now()): RateLimitDecision {\n const entry = entries.get(key) ?? { hits: [] };\n const cutoff = now - windowMs;\n const fresh = entry.hits.filter((timestamp) => timestamp > cutoff);\n\n if (fresh.length >= maxPerWindow) {\n // Refresh LRU order even on denial so an attacker hammering the\n // same key cannot push other tracked keys out via eviction.\n entries.delete(key);\n entries.set(key, { hits: fresh });\n return {\n allowed: false,\n resetAt: (fresh[0] ?? now) + windowMs,\n count: fresh.length,\n };\n }\n fresh.push(now);\n entries.delete(key);\n entries.set(key, { hits: fresh });\n evictIfNeeded();\n return {\n allowed: true,\n resetAt: (fresh[0] ?? now) + windowMs,\n count: fresh.length,\n };\n },\n prune(now = Date.now()): void {\n const cutoff = now - windowMs;\n for (const [key, entry] of entries) {\n const fresh = entry.hits.filter((timestamp) => timestamp > cutoff);\n if (fresh.length === 0) {\n entries.delete(key);\n } else if (fresh.length !== entry.hits.length) {\n entry.hits = fresh;\n }\n }\n },\n size(): number {\n return entries.size;\n },\n reset(): void {\n entries.clear();\n },\n };\n}\n","/**\n * Two-tier rate limiter for free LLM skills (mode='llm', price=0).\n * Provides Sybil-resistant global cap plus per-customer-per-skill cap\n * that respects an optional skill-level override.\n *\n * Both limiters are independent of the existing per-customer general\n * limiter; callers (CLI runtime, plugin handler) check all of them in\n * sequence and only `check()` after every tier passes `peek()` so a\n * single denial does not consume slots in earlier tiers.\n */\n\nimport { createSlidingWindowLimiter, type SlidingWindowLimiter } from '../primitives/rateLimiter';\nimport {\n DEFAULT_FREE_LLM_GLOBAL_MAX,\n DEFAULT_FREE_LLM_GLOBAL_WINDOW_MS,\n DEFAULT_FREE_LLM_MAX_TRACKED_KEYS,\n DEFAULT_FREE_LLM_PER_CUSTOMER_MAX,\n DEFAULT_FREE_LLM_PER_CUSTOMER_WINDOW_MS,\n} from './constants';\nimport type { SkillRateLimit } from './types';\n\nexport const FREE_LLM_GLOBAL_KEY = '__free_llm_global__';\n\nexport interface FreeLlmLimiterOptions {\n /** Max tracked (customer, skill) keys. Default 1000 (LRU evicted past cap). */\n maxKeys?: number;\n /** Default per-customer cap when a free LLM skill omits `rate_limit`. */\n defaultPerCustomer?: SkillRateLimit;\n /** Global Sybil cap across all free LLM jobs. */\n global?: SkillRateLimit;\n}\n\nexport interface FreeLlmLimiterSet {\n /** Sybil-protection limiter keyed on `FREE_LLM_GLOBAL_KEY`. */\n globalLimiter: SlidingWindowLimiter;\n /**\n * Return the per-customer limiter for a given skill. Skills that\n * declare a `rate_limit` get their own limiter sized to that\n * (window, cap); skills that don't share a default-window limiter.\n * Each call uses `peek/check` keyed on `customerId` alone since each\n * skill has a dedicated limiter store.\n */\n getPerCustomerLimiter(\n skillName: string,\n override: SkillRateLimit | undefined,\n ): SlidingWindowLimiter;\n /** Drop expired hits from every per-customer limiter (default + per-skill). */\n prunePerCustomer(): void;\n /** Default cap to apply when a skill omits `rate_limit`. */\n defaultPerCustomer: SkillRateLimit;\n /** Sybil-cap settings (echo of global option for diagnostics). */\n global: SkillRateLimit;\n}\n\n/**\n * Effective per-skill rate limit: the override if it's well-formed,\n * else `undefined` so callers can fall through to the default limiter.\n */\nexport function resolvePerSkillRateLimit(\n skillRateLimit: SkillRateLimit | undefined,\n): SkillRateLimit | undefined {\n if (!skillRateLimit) {\n return undefined;\n }\n if (skillRateLimit.maxPerWindow <= 0 || skillRateLimit.perWindowMs <= 0) {\n return undefined;\n }\n return skillRateLimit;\n}\n\nexport function createFreeLlmLimiterSet(options: FreeLlmLimiterOptions = {}): FreeLlmLimiterSet {\n const defaultPerCustomer: SkillRateLimit = options.defaultPerCustomer ?? {\n perWindowMs: DEFAULT_FREE_LLM_PER_CUSTOMER_WINDOW_MS,\n maxPerWindow: DEFAULT_FREE_LLM_PER_CUSTOMER_MAX,\n };\n const global: SkillRateLimit = options.global ?? {\n perWindowMs: DEFAULT_FREE_LLM_GLOBAL_WINDOW_MS,\n maxPerWindow: DEFAULT_FREE_LLM_GLOBAL_MAX,\n };\n const maxKeys = options.maxKeys ?? DEFAULT_FREE_LLM_MAX_TRACKED_KEYS;\n\n const globalLimiter = createSlidingWindowLimiter({\n windowMs: global.perWindowMs,\n maxPerWindow: global.maxPerWindow,\n maxKeys: 1,\n });\n\n const defaultLimiter = createSlidingWindowLimiter({\n windowMs: defaultPerCustomer.perWindowMs,\n maxPerWindow: defaultPerCustomer.maxPerWindow,\n maxKeys,\n });\n\n const perSkillLimiters = new Map<string, SlidingWindowLimiter>();\n\n function getPerCustomerLimiter(\n skillName: string,\n override: SkillRateLimit | undefined,\n ): SlidingWindowLimiter {\n const effective = resolvePerSkillRateLimit(override);\n if (!effective) {\n return defaultLimiter;\n }\n const cached = perSkillLimiters.get(skillName);\n if (cached) {\n return cached;\n }\n const limiter = createSlidingWindowLimiter({\n windowMs: effective.perWindowMs,\n maxPerWindow: effective.maxPerWindow,\n maxKeys,\n });\n perSkillLimiters.set(skillName, limiter);\n return limiter;\n }\n\n function prunePerCustomer(): void {\n defaultLimiter.prune();\n for (const limiter of perSkillLimiters.values()) {\n limiter.prune();\n }\n }\n\n return {\n globalLimiter,\n getPerCustomerLimiter,\n prunePerCustomer,\n defaultPerCustomer,\n global,\n };\n}\n\n/**\n * Compose a per-skill key for the per-customer limiter. The default\n * limiter is shared across skills without an override, so the skill\n * name is needed to keep each (customer, skill) pair counted\n * independently. Per-skill limiters use the same key for consistency\n * even though the skill component is redundant inside a dedicated\n * limiter store.\n */\nexport function freeLlmCustomerKey(customerId: string, skillName: string): string {\n return `${customerId}|${skillName}`;\n}\n"]}
@@ -0,0 +1,179 @@
1
+ import { L as LlmKeyVerification, a as LlmHealthSnapshotEntry, S as SkillRateLimit } from './types-8vJ1I2KQ.cjs';
2
+ export { b as LlmHealthError, c as LlmHealthErrorReason, d as LlmHealthStatus } from './types-8vJ1I2KQ.cjs';
3
+ import { S as SlidingWindowLimiter } from './rateLimiter-CoEmZkSX.cjs';
4
+
5
+ /**
6
+ * LLM health monitor and heartbeat tunable defaults. CLI and plugin consumers
7
+ * can override via `process.env.ELISYM_LLM_HEALTH_TTL_MS` and
8
+ * `ELISYM_LLM_HEARTBEAT_INTERVAL_MS` and pass the resolved values into
9
+ * the monitor/heartbeat options.
10
+ */
11
+ declare const DEFAULT_HEALTH_TTL_MS: number;
12
+ declare const DEFAULT_HEARTBEAT_INTERVAL_MS: number;
13
+ /**
14
+ * Number of consecutive `unavailable` results tolerated before
15
+ * `assertReady` starts throwing. The first `unavailable - 1` are treated
16
+ * as transient blips so a brief network hiccup does not block jobs.
17
+ */
18
+ declare const UNAVAILABLE_TOLERANCE = 3;
19
+ /**
20
+ * Free-LLM rate-limit defaults. Applied when a SKILL.md with
21
+ * `mode: 'llm'` and `price: 0` does not declare its own `rate_limit`.
22
+ */
23
+ declare const DEFAULT_FREE_LLM_PER_CUSTOMER_WINDOW_MS: number;
24
+ declare const DEFAULT_FREE_LLM_PER_CUSTOMER_MAX = 3;
25
+ declare const DEFAULT_FREE_LLM_GLOBAL_WINDOW_MS: number;
26
+ declare const DEFAULT_FREE_LLM_GLOBAL_MAX = 30;
27
+ declare const DEFAULT_FREE_LLM_MAX_TRACKED_KEYS = 1000;
28
+
29
+ /**
30
+ * Provider-agnostic health monitor for LLM API keys. State machine per
31
+ * (provider, model) pair: caches the last verification result for
32
+ * `ttlMs`, deduplicates concurrent probes via an in-flight promise, and
33
+ * tolerates a bounded number of consecutive `unavailable` results
34
+ * before `assertReady` starts throwing.
35
+ *
36
+ * The monitor itself never touches the network. Each pair must be
37
+ * registered with a `verifyFn` lambda that performs the actual probe;
38
+ * callers (CLI/plugin) supply provider-specific HTTP from their layer.
39
+ */
40
+
41
+ type LlmKeyVerifyFn = (signal?: AbortSignal) => Promise<LlmKeyVerification>;
42
+ interface LlmHealthMonitorOptions {
43
+ /** Time after which a cached `healthy` result is re-probed. Default 10 min. */
44
+ ttlMs?: number;
45
+ /** Number of consecutive unavailable results tolerated. Default 3. */
46
+ unavailableTolerance?: number;
47
+ /** Optional clock injection for tests. */
48
+ now?: () => number;
49
+ }
50
+ interface RegisterArgs {
51
+ provider: string;
52
+ model: string;
53
+ verifyFn: LlmKeyVerifyFn;
54
+ }
55
+ declare class LlmHealthMonitor {
56
+ private readonly entries;
57
+ private readonly ttlMs;
58
+ private readonly unavailableTolerance;
59
+ private readonly now;
60
+ constructor(options?: LlmHealthMonitorOptions);
61
+ /**
62
+ * Register a (provider, model) pair with its probe function. Idempotent
63
+ * on the (provider, model) key: re-registering replaces the verifyFn
64
+ * and resets state to `unknown`. Callers typically re-register only
65
+ * when the operator rotates the API key.
66
+ */
67
+ register(args: RegisterArgs): void;
68
+ /**
69
+ * Seed the monitor with an already-known verification result, e.g.
70
+ * the one captured at startup. Skips an extra probe on the first
71
+ * `assertReady` call within the TTL window. No-op if the pair is not
72
+ * registered.
73
+ */
74
+ seed(provider: string, model: string, verification: LlmKeyVerification): void;
75
+ /**
76
+ * Main gate before doing LLM work. Throws `LlmHealthError` on terminal
77
+ * states (`invalid`, `billing`, or `unavailable` past tolerance);
78
+ * resolves silently on `healthy` (cache hit or fresh probe) or
79
+ * tolerated `unavailable`.
80
+ *
81
+ * Concurrent `assertReady` calls for the same pair are deduplicated:
82
+ * the second caller awaits the first probe rather than launching a
83
+ * parallel one.
84
+ */
85
+ assertReady(provider: string, model: string): Promise<void>;
86
+ /**
87
+ * Force the next `assertReady` for this pair to re-probe regardless of
88
+ * TTL. Used when a real LLM call surfaces 401/402 mid-job - the cached
89
+ * `healthy` is stale and we want to catch the next request.
90
+ */
91
+ markFailureFromJob(provider: string, model: string): void;
92
+ /**
93
+ * Refresh every registered pair concurrently. Heartbeat hook. Errors
94
+ * thrown by `verifyFn` are caught and recorded as `unavailable`.
95
+ */
96
+ refreshAll(): Promise<readonly LlmHealthSnapshotEntry[]>;
97
+ /** Read-only view, primarily for logs and tests. */
98
+ snapshot(): readonly LlmHealthSnapshotEntry[];
99
+ private probeIfNeeded;
100
+ private probe;
101
+ private applyVerification;
102
+ }
103
+
104
+ /**
105
+ * Periodic LLM health probe. Stops on demand. Logs status transitions
106
+ * (healthy <-> unhealthy) but does not log every successful tick to keep
107
+ * the operator log quiet.
108
+ *
109
+ * The heartbeat pure-delegates to `monitor.refreshAll()`; both timing and
110
+ * logging policy live here so the monitor stays a pure state-machine.
111
+ */
112
+
113
+ interface HeartbeatHandle {
114
+ stop(): void;
115
+ }
116
+ interface StartLlmHeartbeatOptions {
117
+ monitor: LlmHealthMonitor;
118
+ /** Defaults to 10 minutes. */
119
+ intervalMs?: number;
120
+ /** Operator log sink. Defaults to no-op (silent). */
121
+ log?: (msg: string) => void;
122
+ }
123
+ declare function startLlmHeartbeat(options: StartLlmHeartbeatOptions): HeartbeatHandle;
124
+
125
+ /**
126
+ * Two-tier rate limiter for free LLM skills (mode='llm', price=0).
127
+ * Provides Sybil-resistant global cap plus per-customer-per-skill cap
128
+ * that respects an optional skill-level override.
129
+ *
130
+ * Both limiters are independent of the existing per-customer general
131
+ * limiter; callers (CLI runtime, plugin handler) check all of them in
132
+ * sequence and only `check()` after every tier passes `peek()` so a
133
+ * single denial does not consume slots in earlier tiers.
134
+ */
135
+
136
+ declare const FREE_LLM_GLOBAL_KEY = "__free_llm_global__";
137
+ interface FreeLlmLimiterOptions {
138
+ /** Max tracked (customer, skill) keys. Default 1000 (LRU evicted past cap). */
139
+ maxKeys?: number;
140
+ /** Default per-customer cap when a free LLM skill omits `rate_limit`. */
141
+ defaultPerCustomer?: SkillRateLimit;
142
+ /** Global Sybil cap across all free LLM jobs. */
143
+ global?: SkillRateLimit;
144
+ }
145
+ interface FreeLlmLimiterSet {
146
+ /** Sybil-protection limiter keyed on `FREE_LLM_GLOBAL_KEY`. */
147
+ globalLimiter: SlidingWindowLimiter;
148
+ /**
149
+ * Return the per-customer limiter for a given skill. Skills that
150
+ * declare a `rate_limit` get their own limiter sized to that
151
+ * (window, cap); skills that don't share a default-window limiter.
152
+ * Each call uses `peek/check` keyed on `customerId` alone since each
153
+ * skill has a dedicated limiter store.
154
+ */
155
+ getPerCustomerLimiter(skillName: string, override: SkillRateLimit | undefined): SlidingWindowLimiter;
156
+ /** Drop expired hits from every per-customer limiter (default + per-skill). */
157
+ prunePerCustomer(): void;
158
+ /** Default cap to apply when a skill omits `rate_limit`. */
159
+ defaultPerCustomer: SkillRateLimit;
160
+ /** Sybil-cap settings (echo of global option for diagnostics). */
161
+ global: SkillRateLimit;
162
+ }
163
+ /**
164
+ * Effective per-skill rate limit: the override if it's well-formed,
165
+ * else `undefined` so callers can fall through to the default limiter.
166
+ */
167
+ declare function resolvePerSkillRateLimit(skillRateLimit: SkillRateLimit | undefined): SkillRateLimit | undefined;
168
+ declare function createFreeLlmLimiterSet(options?: FreeLlmLimiterOptions): FreeLlmLimiterSet;
169
+ /**
170
+ * Compose a per-skill key for the per-customer limiter. The default
171
+ * limiter is shared across skills without an override, so the skill
172
+ * name is needed to keep each (customer, skill) pair counted
173
+ * independently. Per-skill limiters use the same key for consistency
174
+ * even though the skill component is redundant inside a dedicated
175
+ * limiter store.
176
+ */
177
+ declare function freeLlmCustomerKey(customerId: string, skillName: string): string;
178
+
179
+ export { DEFAULT_FREE_LLM_GLOBAL_MAX, DEFAULT_FREE_LLM_GLOBAL_WINDOW_MS, DEFAULT_FREE_LLM_MAX_TRACKED_KEYS, DEFAULT_FREE_LLM_PER_CUSTOMER_MAX, DEFAULT_FREE_LLM_PER_CUSTOMER_WINDOW_MS, DEFAULT_HEALTH_TTL_MS, DEFAULT_HEARTBEAT_INTERVAL_MS, FREE_LLM_GLOBAL_KEY, type FreeLlmLimiterOptions, type FreeLlmLimiterSet, type HeartbeatHandle, LlmHealthMonitor, type LlmHealthMonitorOptions, LlmHealthSnapshotEntry, LlmKeyVerification, type LlmKeyVerifyFn, type RegisterArgs, SkillRateLimit, type StartLlmHeartbeatOptions, UNAVAILABLE_TOLERANCE, createFreeLlmLimiterSet, freeLlmCustomerKey, resolvePerSkillRateLimit, startLlmHeartbeat };