caplyr 0.1.3 → 0.1.8

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.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **AI Cost Control Plane** — Stop runaway API bills automatically.
4
4
 
5
- Caplyr sits between your app and AI providers, controlling how requests execute based on cost constraints. Budget guardrails, auto-downgrade, and kill switch — in 2 lines of code.
5
+ Caplyr wraps your AI client and enforces cost controls based on your project settings in the Caplyr dashboard. Budget guardrails, auto-downgrade, and kill switch — in 2 lines of code.
6
6
 
7
7
  ## Install
8
8
 
@@ -18,9 +18,10 @@ import { protect } from "caplyr";
18
18
 
19
19
  // Wrap your client — everything else stays the same
20
20
  const client = protect(new Anthropic(), {
21
- apiKey: "caplyr_...", // Get yours at https://app.caplyr.com
22
- budget: 500, // Monthly cap in dollars
23
- fallback: "claude-haiku-3-5-20241022", // Auto-downgrade target
21
+ apiKey: "caplyr_...", // Get yours at https://app.caplyr.com
22
+ mode: "cost_protect", // Enforce budget limits
23
+ budget: { monthly: 500, daily: 50 }, // Budget caps in dollars
24
+ fallback: "claude-haiku-4-5-20251001", // Auto-downgrade target
24
25
  });
25
26
 
26
27
  // Use exactly as before — Caplyr is invisible
@@ -39,7 +40,8 @@ import { protect } from "caplyr";
39
40
 
40
41
  const client = protect(new OpenAI(), {
41
42
  apiKey: "caplyr_...",
42
- budget: 500,
43
+ mode: "cost_protect",
44
+ budget: { monthly: 500 },
43
45
  fallback: "gpt-4o-mini",
44
46
  });
45
47
 
@@ -53,42 +55,57 @@ const response = await client.chat.completions.create({
53
55
 
54
56
  | Feature | Description |
55
57
  |---------|-------------|
56
- | **Budget Guardrails** | Daily and monthly caps enforced at the SDK level. Hit the limit → requests are blocked or downgraded. |
58
+ | **Budget Guardrails** | Daily and monthly caps. Hit the limit → requests are blocked. Budget limits are configured in the Caplyr dashboard and enforced via the SDK. |
57
59
  | **Auto Downgrade** | When budget threshold is reached, automatically route to a cheaper model. Your app keeps working. |
58
- | **Kill Switch** | One-click emergency stop. Halts all AI API calls instantly. |
60
+ | **Kill Switch** | One-click emergency stop from the dashboard. Halts all AI API calls instantly. |
61
+
62
+ ## How Enforcement Works
63
+
64
+ Budget limits are managed **server-side** in your Caplyr project settings. The SDK sends your configured `budget` values to the server via heartbeats, and the server returns the current budget status (usage, limits, kill switch state). The SDK enforces limits locally based on that server response — the server is the source of truth, not the local config.
65
+
66
+ If you set `budget` in the SDK but have different limits in your dashboard, the dashboard settings take precedence.
59
67
 
60
68
  ## Modes
61
69
 
62
70
  ```typescript
63
- // Alert-only (default): observe and project, don't enforce
64
- protect(client, { apiKey: "...", mode: "alert_only" });
71
+ // Alert-only (default): observe and log, don't enforce
72
+ protect(client, { apiKey: "..." });
65
73
 
66
74
  // Cost protect: enforce budget caps and auto-downgrade
67
- protect(client, { apiKey: "...", mode: "cost_protect", budget: 500 });
75
+ protect(client, { apiKey: "...", mode: "cost_protect", budget: { monthly: 500 } });
68
76
  ```
69
77
 
78
+ > **Tip:** Setting `budget` without specifying `mode` will automatically enable `cost_protect`.
79
+
80
+ ## Currently Wrapped Endpoints
81
+
82
+ - **Anthropic:** `client.messages.create()`
83
+ - **OpenAI:** `client.chat.completions.create()`
84
+
85
+ Other endpoints (embeddings, images, Responses API) are not yet wrapped. Streaming requests (`stream: true`) are passed through but usage tracking may be incomplete.
86
+
70
87
  ## Configuration
71
88
 
72
89
  | Option | Type | Default | Description |
73
90
  |--------|------|---------|-------------|
74
91
  | `apiKey` | `string` | required | Your Caplyr project API key |
75
- | `budget` | `number` | — | Monthly budget cap in dollars |
76
- | `dailyBudget` | `number` | — | Daily budget cap in dollars |
92
+ | `budget` | `{ monthly?: number, daily?: number }` | — | Budget caps in dollars |
77
93
  | `fallback` | `string` | auto | Fallback model for auto-downgrade |
78
- | `mode` | `string` | `"alert_only"` | `"alert_only"` \| `"cost_protect"` \| `"kill_switch"` |
94
+ | `mode` | `"alert_only" \| "cost_protect"` | `"alert_only"` | Enforcement mode |
79
95
  | `downgradeThreshold` | `number` | `0.8` | Budget % at which downgrade activates |
80
96
  | `endpoint_tag` | `string` | — | Custom tag for cost attribution |
97
+ | `dashboardUrl` | `string` | `https://app.caplyr.com` | Dashboard URL for error messages |
98
+ | `endpoint` | `string` | `https://api.caplyr.com` | API endpoint for heartbeat/ingestion |
81
99
 
82
100
  ## Handling Blocked Requests
83
101
 
84
- When a request is blocked, Caplyr throws a structured error:
102
+ When a request is blocked in `cost_protect` mode, Caplyr throws a structured error:
85
103
 
86
104
  ```typescript
87
105
  try {
88
106
  const response = await client.messages.create({ ... });
89
107
  } catch (err) {
90
108
  if (err.caplyr) {
91
- // Caplyr enforcement event
92
109
  console.log(err.caplyr.code); // "BUDGET_EXCEEDED" | "KILL_SWITCH_ACTIVE"
93
110
  console.log(err.caplyr.retry_after); // ISO timestamp for next reset
94
111
  console.log(err.caplyr.budget_used); // Current spend
@@ -96,19 +113,28 @@ try {
96
113
  }
97
114
  ```
98
115
 
116
+ In `alert_only` mode, the request proceeds and the `onEnforcement` callback is fired instead.
117
+
99
118
  ## Shutdown
100
119
 
120
+ Caplyr does **not** register SIGINT/SIGTERM handlers — your app owns its process lifecycle. Call `shutdown()` in your own signal handler to flush pending logs before exit:
121
+
101
122
  ```typescript
102
123
  import { shutdown } from "caplyr";
103
124
 
104
- // Flush pending logs on app exit
105
- process.on("SIGTERM", () => shutdown());
125
+ async function gracefulShutdown() {
126
+ await shutdown();
127
+ process.exit(0);
128
+ }
129
+
130
+ process.on("SIGTERM", gracefulShutdown);
131
+ process.on("SIGINT", gracefulShutdown);
106
132
  ```
107
133
 
108
134
  ## Links
109
135
 
110
136
  - **Dashboard**: [app.caplyr.com](https://app.caplyr.com)
111
- - **Docs**: [docs.caplyr.com](https://docs.caplyr.com)
137
+ - **Docs**: [caplyr.com/docs](https://caplyr.com/docs)
112
138
  - **Website**: [caplyr.com](https://caplyr.com)
113
139
 
114
140
  ## License
package/dist/index.d.mts CHANGED
@@ -1,13 +1,17 @@
1
1
  interface CaplyrConfig {
2
2
  apiKey: string;
3
3
  mode?: 'alert_only' | 'cost_protect';
4
+ /** Budget limits. Enforcement is server-side, based on project settings tied to your API key. */
4
5
  budget?: {
5
6
  daily?: number;
6
7
  monthly?: number;
7
8
  };
8
9
  downgradeThreshold?: number;
9
10
  fallback?: string;
11
+ /** API endpoint for heartbeat and log ingestion (default: https://api.caplyr.com) */
10
12
  endpoint?: string;
13
+ /** Dashboard URL for error messages (default: https://app.caplyr.com) */
14
+ dashboardUrl?: string;
11
15
  endpoint_tag?: string;
12
16
  batchSize?: number;
13
17
  flushInterval?: number;
@@ -18,6 +22,9 @@ interface CaplyrConfig {
18
22
  interface ResolvedConfig extends CaplyrConfig {
19
23
  mode: 'alert_only' | 'cost_protect';
20
24
  downgradeThreshold: number;
25
+ dashboardUrl: string;
26
+ /** @internal Called by interceptors to track request count */
27
+ _onRequest?: () => void;
21
28
  }
22
29
  declare function protect(client: any, config: CaplyrConfig): any;
23
30
  declare function getStatus(apiKey: string): string;
@@ -27,10 +34,9 @@ declare function getState(apiKey: string): {
27
34
  budget_daily_used: number;
28
35
  budget_monthly_used: number;
29
36
  kill_switch_active: boolean;
30
- last_heartbeat: number;
37
+ last_heartbeat: number | null;
31
38
  request_count: number;
32
39
  total_cost: number;
33
- total_savings: number;
34
40
  } | null;
35
41
  declare function shutdown(apiKey?: string): Promise<void>;
36
42
 
package/dist/index.d.ts CHANGED
@@ -1,13 +1,17 @@
1
1
  interface CaplyrConfig {
2
2
  apiKey: string;
3
3
  mode?: 'alert_only' | 'cost_protect';
4
+ /** Budget limits. Enforcement is server-side, based on project settings tied to your API key. */
4
5
  budget?: {
5
6
  daily?: number;
6
7
  monthly?: number;
7
8
  };
8
9
  downgradeThreshold?: number;
9
10
  fallback?: string;
11
+ /** API endpoint for heartbeat and log ingestion (default: https://api.caplyr.com) */
10
12
  endpoint?: string;
13
+ /** Dashboard URL for error messages (default: https://app.caplyr.com) */
14
+ dashboardUrl?: string;
11
15
  endpoint_tag?: string;
12
16
  batchSize?: number;
13
17
  flushInterval?: number;
@@ -18,6 +22,9 @@ interface CaplyrConfig {
18
22
  interface ResolvedConfig extends CaplyrConfig {
19
23
  mode: 'alert_only' | 'cost_protect';
20
24
  downgradeThreshold: number;
25
+ dashboardUrl: string;
26
+ /** @internal Called by interceptors to track request count */
27
+ _onRequest?: () => void;
21
28
  }
22
29
  declare function protect(client: any, config: CaplyrConfig): any;
23
30
  declare function getStatus(apiKey: string): string;
@@ -27,10 +34,9 @@ declare function getState(apiKey: string): {
27
34
  budget_daily_used: number;
28
35
  budget_monthly_used: number;
29
36
  kill_switch_active: boolean;
30
- last_heartbeat: number;
37
+ last_heartbeat: number | null;
31
38
  request_count: number;
32
39
  total_cost: number;
33
- total_savings: number;
34
40
  } | null;
35
41
  declare function shutdown(apiKey?: string): Promise<void>;
36
42
 
package/dist/index.js CHANGED
@@ -33,6 +33,19 @@ module.exports = __toCommonJS(index_exports);
33
33
 
34
34
  // src/logger.ts
35
35
  var DEFAULT_MAX_BUFFER = 500;
36
+ var activeShippers = /* @__PURE__ */ new Set();
37
+ var processHandlersRegistered = false;
38
+ function registerProcessHandlers() {
39
+ if (processHandlersRegistered) return;
40
+ if (typeof process === "undefined" || !process.on) return;
41
+ processHandlersRegistered = true;
42
+ process.on("beforeExit", () => {
43
+ for (const shipper of activeShippers) {
44
+ shipper.flush().catch(() => {
45
+ });
46
+ }
47
+ });
48
+ }
36
49
  var LogShipper = class {
37
50
  constructor(config) {
38
51
  this.buffer = [];
@@ -44,15 +57,9 @@ var LogShipper = class {
44
57
  this.maxBufferSize = config.maxBufferSize ?? DEFAULT_MAX_BUFFER;
45
58
  this.onError = config.onError;
46
59
  this.timer = setInterval(() => this.flush(), this.flushInterval);
47
- if (typeof process !== "undefined" && process.on) {
48
- const flushAndExit = () => {
49
- this.flush().finally(() => {
50
- });
51
- };
52
- process.on("beforeExit", () => this.flush());
53
- process.on("SIGTERM", flushAndExit);
54
- process.on("SIGINT", flushAndExit);
55
- }
60
+ if (this.timer.unref) this.timer.unref();
61
+ activeShippers.add(this);
62
+ registerProcessHandlers();
56
63
  }
57
64
  push(log) {
58
65
  this.buffer.push(log);
@@ -75,9 +82,10 @@ var LogShipper = class {
75
82
  });
76
83
  if (!res.ok) {
77
84
  this.requeueFailed(batch);
78
- throw new Error(`Ingest failed: ${res.status} ${res.statusText}`);
85
+ this.onError?.(new Error(`Ingest failed: ${res.status} ${res.statusText}`));
79
86
  }
80
87
  } catch (err) {
88
+ this.requeueFailed(batch);
81
89
  this.onError?.(err instanceof Error ? err : new Error(String(err)));
82
90
  }
83
91
  }
@@ -90,19 +98,17 @@ var LogShipper = class {
90
98
  const toKeep = batch.slice(-available);
91
99
  this.buffer.unshift(...toKeep);
92
100
  }
93
- destroy() {
101
+ async destroy() {
94
102
  if (this.timer) {
95
103
  clearInterval(this.timer);
96
104
  this.timer = null;
97
105
  }
98
- this.flush();
106
+ await this.flush();
107
+ activeShippers.delete(this);
99
108
  }
109
+ /** @deprecated Use destroy() instead */
100
110
  async shutdown() {
101
- if (this.timer) {
102
- clearInterval(this.timer);
103
- this.timer = null;
104
- }
105
- await this.flush();
111
+ return this.destroy();
106
112
  }
107
113
  };
108
114
 
@@ -121,15 +127,18 @@ var Heartbeat = class {
121
127
  kill_switch_active: false
122
128
  };
123
129
  this.status = "ACTIVE";
130
+ this.lastHeartbeatAt = null;
124
131
  this.endpoint = config.endpoint ?? "https://api.caplyr.com";
125
132
  this.apiKey = config.apiKey;
126
133
  this.interval = config.heartbeatInterval ?? 6e4;
134
+ this.budget = config.budget;
127
135
  this.onStatusChange = config.onStatusChange;
128
136
  this.onError = config.onError;
129
137
  }
130
138
  start() {
131
139
  this.beat();
132
140
  this.timer = setInterval(() => this.beat(), this.interval);
141
+ if (this.timer.unref) this.timer.unref();
133
142
  }
134
143
  async beat() {
135
144
  try {
@@ -139,13 +148,17 @@ var Heartbeat = class {
139
148
  "Content-Type": "application/json",
140
149
  "Authorization": `Bearer ${this.apiKey}`
141
150
  },
142
- body: JSON.stringify({ timestamp: Date.now() }),
151
+ body: JSON.stringify({
152
+ timestamp: Date.now(),
153
+ ...this.budget && { budget: this.budget }
154
+ }),
143
155
  signal: AbortSignal.timeout(5e3)
144
156
  });
145
157
  if (!res.ok) throw new Error(`Heartbeat failed: ${res.status}`);
146
158
  const data = await res.json();
147
159
  this.budgetStatus = data;
148
160
  this.consecutiveFailures = 0;
161
+ this.lastHeartbeatAt = Date.now();
149
162
  const newStatus = data.kill_switch_active ? "OFF" : data.status;
150
163
  if (newStatus !== this.status) {
151
164
  this.status = newStatus;
@@ -267,8 +280,9 @@ function getNextResetTime(reason) {
267
280
 
268
281
  // src/interceptors/enforce.ts
269
282
  function enforcePreCall(model, config, heartbeat, shipper, provider, startTime) {
270
- const dashboardUrl = `${config.endpoint ?? "https://app.caplyr.com"}/dashboard`;
283
+ const dashboardUrl = `${config.dashboardUrl.replace(/\/dashboard\/?$/, "")}/dashboard`;
271
284
  const downgradeThreshold = config.downgradeThreshold ?? 0.8;
285
+ config._onRequest?.();
272
286
  if (heartbeat.isKillSwitchActive()) {
273
287
  const reason = "kill_switch_active";
274
288
  const blockError = {
@@ -279,16 +293,13 @@ function enforcePreCall(model, config, heartbeat, shipper, provider, startTime)
279
293
  dashboard_url: dashboardUrl
280
294
  };
281
295
  pushBlockedLog(shipper, provider, model, startTime, reason, config.endpoint_tag);
282
- if (config.mode === "alert_only") {
283
- config.onEnforcement?.({
284
- type: "kill_switch",
285
- timestamp: Date.now(),
286
- reason: blockError.message,
287
- budget_used: blockError.budget_used,
288
- budget_limit: blockError.budget_limit
289
- });
290
- return { proceed: true, model, downgraded: false, enforcementReason: reason };
291
- }
296
+ config.onEnforcement?.({
297
+ type: "kill_switch",
298
+ timestamp: Date.now(),
299
+ reason: blockError.message,
300
+ budget_used: blockError.budget_used,
301
+ budget_limit: blockError.budget_limit
302
+ });
292
303
  throw Object.assign(new Error(blockError.message), { caplyr: blockError });
293
304
  }
294
305
  if (config.mode === "cost_protect") {
@@ -330,7 +341,7 @@ function enforcePreCall(model, config, heartbeat, shipper, provider, startTime)
330
341
  }
331
342
  return { proceed: true, model, downgraded: false };
332
343
  }
333
- function logSuccess(shipper, provider, model, startTime, inputTokens, outputTokens, enforcement, heartbeat, endpointTag) {
344
+ function logSuccess(shipper, provider, model, startTime, inputTokens, outputTokens, enforcement, heartbeat, config) {
334
345
  const cost = calculateCost(model, inputTokens, outputTokens);
335
346
  heartbeat.trackSpend(cost);
336
347
  shipper.push({
@@ -342,7 +353,7 @@ function logSuccess(shipper, provider, model, startTime, inputTokens, outputToke
342
353
  output_tokens: outputTokens,
343
354
  cost,
344
355
  latency_ms: Date.now() - startTime,
345
- endpoint_tag: endpointTag,
356
+ endpoint_tag: config.endpoint_tag,
346
357
  downgraded: enforcement.downgraded,
347
358
  original_model: enforcement.originalModel,
348
359
  blocked: false,
@@ -350,7 +361,7 @@ function logSuccess(shipper, provider, model, startTime, inputTokens, outputToke
350
361
  });
351
362
  return cost;
352
363
  }
353
- function logProviderError(shipper, provider, model, startTime, enforcement, endpointTag) {
364
+ function logProviderError(shipper, provider, model, startTime, enforcement, config) {
354
365
  shipper.push({
355
366
  id: generateId(),
356
367
  timestamp: startTime,
@@ -360,7 +371,7 @@ function logProviderError(shipper, provider, model, startTime, enforcement, endp
360
371
  output_tokens: 0,
361
372
  cost: 0,
362
373
  latency_ms: Date.now() - startTime,
363
- endpoint_tag: endpointTag,
374
+ endpoint_tag: config.endpoint_tag,
364
375
  downgraded: enforcement.downgraded,
365
376
  original_model: enforcement.originalModel,
366
377
  blocked: false,
@@ -413,7 +424,7 @@ function wrapAnthropic(client, config, shipper, heartbeat) {
413
424
  outputTokens,
414
425
  enforcement,
415
426
  heartbeat,
416
- config.endpoint_tag
427
+ config
417
428
  );
418
429
  return response;
419
430
  } catch (err) {
@@ -424,7 +435,7 @@ function wrapAnthropic(client, config, shipper, heartbeat) {
424
435
  enforcement.model,
425
436
  startTime,
426
437
  enforcement,
427
- config.endpoint_tag
438
+ config
428
439
  );
429
440
  throw err;
430
441
  }
@@ -471,7 +482,7 @@ function wrapOpenAI(client, config, shipper, heartbeat) {
471
482
  outputTokens,
472
483
  enforcement,
473
484
  heartbeat,
474
- config.endpoint_tag
485
+ config
475
486
  );
476
487
  return response;
477
488
  } catch (err) {
@@ -482,7 +493,7 @@ function wrapOpenAI(client, config, shipper, heartbeat) {
482
493
  enforcement.model,
483
494
  startTime,
484
495
  enforcement,
485
- config.endpoint_tag
496
+ config
486
497
  );
487
498
  throw err;
488
499
  }
@@ -524,7 +535,8 @@ function protect(client, config) {
524
535
  const resolvedConfig = {
525
536
  ...config,
526
537
  mode,
527
- downgradeThreshold: config.downgradeThreshold ?? 0.8
538
+ downgradeThreshold: config.downgradeThreshold ?? 0.8,
539
+ dashboardUrl: config.dashboardUrl ?? "https://app.caplyr.com"
528
540
  };
529
541
  let shared = instances.get(resolvedConfig.apiKey);
530
542
  if (!shared) {
@@ -535,6 +547,10 @@ function protect(client, config) {
535
547
  instances.set(resolvedConfig.apiKey, shared);
536
548
  }
537
549
  const { shipper, heartbeat } = shared;
550
+ const sharedRef = shared;
551
+ resolvedConfig._onRequest = () => {
552
+ sharedRef.requestCount++;
553
+ };
538
554
  const provider = detectProvider(client);
539
555
  switch (provider) {
540
556
  case "anthropic":
@@ -560,10 +576,9 @@ function getState(apiKey) {
560
576
  budget_daily_used: heartbeat.budgetStatus.daily_used,
561
577
  budget_monthly_used: heartbeat.budgetStatus.monthly_used,
562
578
  kill_switch_active: heartbeat.budgetStatus.kill_switch_active,
563
- last_heartbeat: Date.now(),
579
+ last_heartbeat: heartbeat.lastHeartbeatAt,
564
580
  request_count: requestCount,
565
- total_cost: heartbeat.budgetStatus.monthly_used,
566
- total_savings: 0
581
+ total_cost: heartbeat.budgetStatus.monthly_used
567
582
  };
568
583
  }
569
584
  async function shutdown(apiKey) {
@@ -571,14 +586,16 @@ async function shutdown(apiKey) {
571
586
  const shared = instances.get(apiKey);
572
587
  if (shared) {
573
588
  shared.heartbeat.destroy();
574
- shared.shipper.destroy();
589
+ await shared.shipper.destroy();
575
590
  instances.delete(apiKey);
576
591
  }
577
592
  } else {
593
+ const promises = [];
578
594
  for (const [, shared] of instances) {
579
595
  shared.heartbeat.destroy();
580
- shared.shipper.destroy();
596
+ promises.push(shared.shipper.destroy());
581
597
  }
598
+ await Promise.all(promises);
582
599
  instances.clear();
583
600
  }
584
601
  }
package/dist/index.mjs CHANGED
@@ -1,5 +1,18 @@
1
1
  // src/logger.ts
2
2
  var DEFAULT_MAX_BUFFER = 500;
3
+ var activeShippers = /* @__PURE__ */ new Set();
4
+ var processHandlersRegistered = false;
5
+ function registerProcessHandlers() {
6
+ if (processHandlersRegistered) return;
7
+ if (typeof process === "undefined" || !process.on) return;
8
+ processHandlersRegistered = true;
9
+ process.on("beforeExit", () => {
10
+ for (const shipper of activeShippers) {
11
+ shipper.flush().catch(() => {
12
+ });
13
+ }
14
+ });
15
+ }
3
16
  var LogShipper = class {
4
17
  constructor(config) {
5
18
  this.buffer = [];
@@ -11,15 +24,9 @@ var LogShipper = class {
11
24
  this.maxBufferSize = config.maxBufferSize ?? DEFAULT_MAX_BUFFER;
12
25
  this.onError = config.onError;
13
26
  this.timer = setInterval(() => this.flush(), this.flushInterval);
14
- if (typeof process !== "undefined" && process.on) {
15
- const flushAndExit = () => {
16
- this.flush().finally(() => {
17
- });
18
- };
19
- process.on("beforeExit", () => this.flush());
20
- process.on("SIGTERM", flushAndExit);
21
- process.on("SIGINT", flushAndExit);
22
- }
27
+ if (this.timer.unref) this.timer.unref();
28
+ activeShippers.add(this);
29
+ registerProcessHandlers();
23
30
  }
24
31
  push(log) {
25
32
  this.buffer.push(log);
@@ -42,9 +49,10 @@ var LogShipper = class {
42
49
  });
43
50
  if (!res.ok) {
44
51
  this.requeueFailed(batch);
45
- throw new Error(`Ingest failed: ${res.status} ${res.statusText}`);
52
+ this.onError?.(new Error(`Ingest failed: ${res.status} ${res.statusText}`));
46
53
  }
47
54
  } catch (err) {
55
+ this.requeueFailed(batch);
48
56
  this.onError?.(err instanceof Error ? err : new Error(String(err)));
49
57
  }
50
58
  }
@@ -57,19 +65,17 @@ var LogShipper = class {
57
65
  const toKeep = batch.slice(-available);
58
66
  this.buffer.unshift(...toKeep);
59
67
  }
60
- destroy() {
68
+ async destroy() {
61
69
  if (this.timer) {
62
70
  clearInterval(this.timer);
63
71
  this.timer = null;
64
72
  }
65
- this.flush();
73
+ await this.flush();
74
+ activeShippers.delete(this);
66
75
  }
76
+ /** @deprecated Use destroy() instead */
67
77
  async shutdown() {
68
- if (this.timer) {
69
- clearInterval(this.timer);
70
- this.timer = null;
71
- }
72
- await this.flush();
78
+ return this.destroy();
73
79
  }
74
80
  };
75
81
 
@@ -88,15 +94,18 @@ var Heartbeat = class {
88
94
  kill_switch_active: false
89
95
  };
90
96
  this.status = "ACTIVE";
97
+ this.lastHeartbeatAt = null;
91
98
  this.endpoint = config.endpoint ?? "https://api.caplyr.com";
92
99
  this.apiKey = config.apiKey;
93
100
  this.interval = config.heartbeatInterval ?? 6e4;
101
+ this.budget = config.budget;
94
102
  this.onStatusChange = config.onStatusChange;
95
103
  this.onError = config.onError;
96
104
  }
97
105
  start() {
98
106
  this.beat();
99
107
  this.timer = setInterval(() => this.beat(), this.interval);
108
+ if (this.timer.unref) this.timer.unref();
100
109
  }
101
110
  async beat() {
102
111
  try {
@@ -106,13 +115,17 @@ var Heartbeat = class {
106
115
  "Content-Type": "application/json",
107
116
  "Authorization": `Bearer ${this.apiKey}`
108
117
  },
109
- body: JSON.stringify({ timestamp: Date.now() }),
118
+ body: JSON.stringify({
119
+ timestamp: Date.now(),
120
+ ...this.budget && { budget: this.budget }
121
+ }),
110
122
  signal: AbortSignal.timeout(5e3)
111
123
  });
112
124
  if (!res.ok) throw new Error(`Heartbeat failed: ${res.status}`);
113
125
  const data = await res.json();
114
126
  this.budgetStatus = data;
115
127
  this.consecutiveFailures = 0;
128
+ this.lastHeartbeatAt = Date.now();
116
129
  const newStatus = data.kill_switch_active ? "OFF" : data.status;
117
130
  if (newStatus !== this.status) {
118
131
  this.status = newStatus;
@@ -234,8 +247,9 @@ function getNextResetTime(reason) {
234
247
 
235
248
  // src/interceptors/enforce.ts
236
249
  function enforcePreCall(model, config, heartbeat, shipper, provider, startTime) {
237
- const dashboardUrl = `${config.endpoint ?? "https://app.caplyr.com"}/dashboard`;
250
+ const dashboardUrl = `${config.dashboardUrl.replace(/\/dashboard\/?$/, "")}/dashboard`;
238
251
  const downgradeThreshold = config.downgradeThreshold ?? 0.8;
252
+ config._onRequest?.();
239
253
  if (heartbeat.isKillSwitchActive()) {
240
254
  const reason = "kill_switch_active";
241
255
  const blockError = {
@@ -246,16 +260,13 @@ function enforcePreCall(model, config, heartbeat, shipper, provider, startTime)
246
260
  dashboard_url: dashboardUrl
247
261
  };
248
262
  pushBlockedLog(shipper, provider, model, startTime, reason, config.endpoint_tag);
249
- if (config.mode === "alert_only") {
250
- config.onEnforcement?.({
251
- type: "kill_switch",
252
- timestamp: Date.now(),
253
- reason: blockError.message,
254
- budget_used: blockError.budget_used,
255
- budget_limit: blockError.budget_limit
256
- });
257
- return { proceed: true, model, downgraded: false, enforcementReason: reason };
258
- }
263
+ config.onEnforcement?.({
264
+ type: "kill_switch",
265
+ timestamp: Date.now(),
266
+ reason: blockError.message,
267
+ budget_used: blockError.budget_used,
268
+ budget_limit: blockError.budget_limit
269
+ });
259
270
  throw Object.assign(new Error(blockError.message), { caplyr: blockError });
260
271
  }
261
272
  if (config.mode === "cost_protect") {
@@ -297,7 +308,7 @@ function enforcePreCall(model, config, heartbeat, shipper, provider, startTime)
297
308
  }
298
309
  return { proceed: true, model, downgraded: false };
299
310
  }
300
- function logSuccess(shipper, provider, model, startTime, inputTokens, outputTokens, enforcement, heartbeat, endpointTag) {
311
+ function logSuccess(shipper, provider, model, startTime, inputTokens, outputTokens, enforcement, heartbeat, config) {
301
312
  const cost = calculateCost(model, inputTokens, outputTokens);
302
313
  heartbeat.trackSpend(cost);
303
314
  shipper.push({
@@ -309,7 +320,7 @@ function logSuccess(shipper, provider, model, startTime, inputTokens, outputToke
309
320
  output_tokens: outputTokens,
310
321
  cost,
311
322
  latency_ms: Date.now() - startTime,
312
- endpoint_tag: endpointTag,
323
+ endpoint_tag: config.endpoint_tag,
313
324
  downgraded: enforcement.downgraded,
314
325
  original_model: enforcement.originalModel,
315
326
  blocked: false,
@@ -317,7 +328,7 @@ function logSuccess(shipper, provider, model, startTime, inputTokens, outputToke
317
328
  });
318
329
  return cost;
319
330
  }
320
- function logProviderError(shipper, provider, model, startTime, enforcement, endpointTag) {
331
+ function logProviderError(shipper, provider, model, startTime, enforcement, config) {
321
332
  shipper.push({
322
333
  id: generateId(),
323
334
  timestamp: startTime,
@@ -327,7 +338,7 @@ function logProviderError(shipper, provider, model, startTime, enforcement, endp
327
338
  output_tokens: 0,
328
339
  cost: 0,
329
340
  latency_ms: Date.now() - startTime,
330
- endpoint_tag: endpointTag,
341
+ endpoint_tag: config.endpoint_tag,
331
342
  downgraded: enforcement.downgraded,
332
343
  original_model: enforcement.originalModel,
333
344
  blocked: false,
@@ -380,7 +391,7 @@ function wrapAnthropic(client, config, shipper, heartbeat) {
380
391
  outputTokens,
381
392
  enforcement,
382
393
  heartbeat,
383
- config.endpoint_tag
394
+ config
384
395
  );
385
396
  return response;
386
397
  } catch (err) {
@@ -391,7 +402,7 @@ function wrapAnthropic(client, config, shipper, heartbeat) {
391
402
  enforcement.model,
392
403
  startTime,
393
404
  enforcement,
394
- config.endpoint_tag
405
+ config
395
406
  );
396
407
  throw err;
397
408
  }
@@ -438,7 +449,7 @@ function wrapOpenAI(client, config, shipper, heartbeat) {
438
449
  outputTokens,
439
450
  enforcement,
440
451
  heartbeat,
441
- config.endpoint_tag
452
+ config
442
453
  );
443
454
  return response;
444
455
  } catch (err) {
@@ -449,7 +460,7 @@ function wrapOpenAI(client, config, shipper, heartbeat) {
449
460
  enforcement.model,
450
461
  startTime,
451
462
  enforcement,
452
- config.endpoint_tag
463
+ config
453
464
  );
454
465
  throw err;
455
466
  }
@@ -491,7 +502,8 @@ function protect(client, config) {
491
502
  const resolvedConfig = {
492
503
  ...config,
493
504
  mode,
494
- downgradeThreshold: config.downgradeThreshold ?? 0.8
505
+ downgradeThreshold: config.downgradeThreshold ?? 0.8,
506
+ dashboardUrl: config.dashboardUrl ?? "https://app.caplyr.com"
495
507
  };
496
508
  let shared = instances.get(resolvedConfig.apiKey);
497
509
  if (!shared) {
@@ -502,6 +514,10 @@ function protect(client, config) {
502
514
  instances.set(resolvedConfig.apiKey, shared);
503
515
  }
504
516
  const { shipper, heartbeat } = shared;
517
+ const sharedRef = shared;
518
+ resolvedConfig._onRequest = () => {
519
+ sharedRef.requestCount++;
520
+ };
505
521
  const provider = detectProvider(client);
506
522
  switch (provider) {
507
523
  case "anthropic":
@@ -527,10 +543,9 @@ function getState(apiKey) {
527
543
  budget_daily_used: heartbeat.budgetStatus.daily_used,
528
544
  budget_monthly_used: heartbeat.budgetStatus.monthly_used,
529
545
  kill_switch_active: heartbeat.budgetStatus.kill_switch_active,
530
- last_heartbeat: Date.now(),
546
+ last_heartbeat: heartbeat.lastHeartbeatAt,
531
547
  request_count: requestCount,
532
- total_cost: heartbeat.budgetStatus.monthly_used,
533
- total_savings: 0
548
+ total_cost: heartbeat.budgetStatus.monthly_used
534
549
  };
535
550
  }
536
551
  async function shutdown(apiKey) {
@@ -538,14 +553,16 @@ async function shutdown(apiKey) {
538
553
  const shared = instances.get(apiKey);
539
554
  if (shared) {
540
555
  shared.heartbeat.destroy();
541
- shared.shipper.destroy();
556
+ await shared.shipper.destroy();
542
557
  instances.delete(apiKey);
543
558
  }
544
559
  } else {
560
+ const promises = [];
545
561
  for (const [, shared] of instances) {
546
562
  shared.heartbeat.destroy();
547
- shared.shipper.destroy();
563
+ promises.push(shared.shipper.destroy());
548
564
  }
565
+ await Promise.all(promises);
549
566
  instances.clear();
550
567
  }
551
568
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "caplyr",
3
- "version": "0.1.3",
3
+ "version": "0.1.8",
4
4
  "description": "AI Cost Control Plane — budget guardrails, auto-downgrade, and kill switch for AI API calls",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",