caplyr 0.1.3 → 0.1.7

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 = {
@@ -330,7 +344,7 @@ function enforcePreCall(model, config, heartbeat, shipper, provider, startTime)
330
344
  }
331
345
  return { proceed: true, model, downgraded: false };
332
346
  }
333
- function logSuccess(shipper, provider, model, startTime, inputTokens, outputTokens, enforcement, heartbeat, endpointTag) {
347
+ function logSuccess(shipper, provider, model, startTime, inputTokens, outputTokens, enforcement, heartbeat, config) {
334
348
  const cost = calculateCost(model, inputTokens, outputTokens);
335
349
  heartbeat.trackSpend(cost);
336
350
  shipper.push({
@@ -342,7 +356,7 @@ function logSuccess(shipper, provider, model, startTime, inputTokens, outputToke
342
356
  output_tokens: outputTokens,
343
357
  cost,
344
358
  latency_ms: Date.now() - startTime,
345
- endpoint_tag: endpointTag,
359
+ endpoint_tag: config.endpoint_tag,
346
360
  downgraded: enforcement.downgraded,
347
361
  original_model: enforcement.originalModel,
348
362
  blocked: false,
@@ -350,7 +364,7 @@ function logSuccess(shipper, provider, model, startTime, inputTokens, outputToke
350
364
  });
351
365
  return cost;
352
366
  }
353
- function logProviderError(shipper, provider, model, startTime, enforcement, endpointTag) {
367
+ function logProviderError(shipper, provider, model, startTime, enforcement, config) {
354
368
  shipper.push({
355
369
  id: generateId(),
356
370
  timestamp: startTime,
@@ -360,7 +374,7 @@ function logProviderError(shipper, provider, model, startTime, enforcement, endp
360
374
  output_tokens: 0,
361
375
  cost: 0,
362
376
  latency_ms: Date.now() - startTime,
363
- endpoint_tag: endpointTag,
377
+ endpoint_tag: config.endpoint_tag,
364
378
  downgraded: enforcement.downgraded,
365
379
  original_model: enforcement.originalModel,
366
380
  blocked: false,
@@ -413,7 +427,7 @@ function wrapAnthropic(client, config, shipper, heartbeat) {
413
427
  outputTokens,
414
428
  enforcement,
415
429
  heartbeat,
416
- config.endpoint_tag
430
+ config
417
431
  );
418
432
  return response;
419
433
  } catch (err) {
@@ -424,7 +438,7 @@ function wrapAnthropic(client, config, shipper, heartbeat) {
424
438
  enforcement.model,
425
439
  startTime,
426
440
  enforcement,
427
- config.endpoint_tag
441
+ config
428
442
  );
429
443
  throw err;
430
444
  }
@@ -471,7 +485,7 @@ function wrapOpenAI(client, config, shipper, heartbeat) {
471
485
  outputTokens,
472
486
  enforcement,
473
487
  heartbeat,
474
- config.endpoint_tag
488
+ config
475
489
  );
476
490
  return response;
477
491
  } catch (err) {
@@ -482,7 +496,7 @@ function wrapOpenAI(client, config, shipper, heartbeat) {
482
496
  enforcement.model,
483
497
  startTime,
484
498
  enforcement,
485
- config.endpoint_tag
499
+ config
486
500
  );
487
501
  throw err;
488
502
  }
@@ -524,7 +538,8 @@ function protect(client, config) {
524
538
  const resolvedConfig = {
525
539
  ...config,
526
540
  mode,
527
- downgradeThreshold: config.downgradeThreshold ?? 0.8
541
+ downgradeThreshold: config.downgradeThreshold ?? 0.8,
542
+ dashboardUrl: config.dashboardUrl ?? "https://app.caplyr.com"
528
543
  };
529
544
  let shared = instances.get(resolvedConfig.apiKey);
530
545
  if (!shared) {
@@ -535,6 +550,10 @@ function protect(client, config) {
535
550
  instances.set(resolvedConfig.apiKey, shared);
536
551
  }
537
552
  const { shipper, heartbeat } = shared;
553
+ const sharedRef = shared;
554
+ resolvedConfig._onRequest = () => {
555
+ sharedRef.requestCount++;
556
+ };
538
557
  const provider = detectProvider(client);
539
558
  switch (provider) {
540
559
  case "anthropic":
@@ -560,10 +579,9 @@ function getState(apiKey) {
560
579
  budget_daily_used: heartbeat.budgetStatus.daily_used,
561
580
  budget_monthly_used: heartbeat.budgetStatus.monthly_used,
562
581
  kill_switch_active: heartbeat.budgetStatus.kill_switch_active,
563
- last_heartbeat: Date.now(),
582
+ last_heartbeat: heartbeat.lastHeartbeatAt,
564
583
  request_count: requestCount,
565
- total_cost: heartbeat.budgetStatus.monthly_used,
566
- total_savings: 0
584
+ total_cost: heartbeat.budgetStatus.monthly_used
567
585
  };
568
586
  }
569
587
  async function shutdown(apiKey) {
@@ -571,14 +589,16 @@ async function shutdown(apiKey) {
571
589
  const shared = instances.get(apiKey);
572
590
  if (shared) {
573
591
  shared.heartbeat.destroy();
574
- shared.shipper.destroy();
592
+ await shared.shipper.destroy();
575
593
  instances.delete(apiKey);
576
594
  }
577
595
  } else {
596
+ const promises = [];
578
597
  for (const [, shared] of instances) {
579
598
  shared.heartbeat.destroy();
580
- shared.shipper.destroy();
599
+ promises.push(shared.shipper.destroy());
581
600
  }
601
+ await Promise.all(promises);
582
602
  instances.clear();
583
603
  }
584
604
  }
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 = {
@@ -297,7 +311,7 @@ function enforcePreCall(model, config, heartbeat, shipper, provider, startTime)
297
311
  }
298
312
  return { proceed: true, model, downgraded: false };
299
313
  }
300
- function logSuccess(shipper, provider, model, startTime, inputTokens, outputTokens, enforcement, heartbeat, endpointTag) {
314
+ function logSuccess(shipper, provider, model, startTime, inputTokens, outputTokens, enforcement, heartbeat, config) {
301
315
  const cost = calculateCost(model, inputTokens, outputTokens);
302
316
  heartbeat.trackSpend(cost);
303
317
  shipper.push({
@@ -309,7 +323,7 @@ function logSuccess(shipper, provider, model, startTime, inputTokens, outputToke
309
323
  output_tokens: outputTokens,
310
324
  cost,
311
325
  latency_ms: Date.now() - startTime,
312
- endpoint_tag: endpointTag,
326
+ endpoint_tag: config.endpoint_tag,
313
327
  downgraded: enforcement.downgraded,
314
328
  original_model: enforcement.originalModel,
315
329
  blocked: false,
@@ -317,7 +331,7 @@ function logSuccess(shipper, provider, model, startTime, inputTokens, outputToke
317
331
  });
318
332
  return cost;
319
333
  }
320
- function logProviderError(shipper, provider, model, startTime, enforcement, endpointTag) {
334
+ function logProviderError(shipper, provider, model, startTime, enforcement, config) {
321
335
  shipper.push({
322
336
  id: generateId(),
323
337
  timestamp: startTime,
@@ -327,7 +341,7 @@ function logProviderError(shipper, provider, model, startTime, enforcement, endp
327
341
  output_tokens: 0,
328
342
  cost: 0,
329
343
  latency_ms: Date.now() - startTime,
330
- endpoint_tag: endpointTag,
344
+ endpoint_tag: config.endpoint_tag,
331
345
  downgraded: enforcement.downgraded,
332
346
  original_model: enforcement.originalModel,
333
347
  blocked: false,
@@ -380,7 +394,7 @@ function wrapAnthropic(client, config, shipper, heartbeat) {
380
394
  outputTokens,
381
395
  enforcement,
382
396
  heartbeat,
383
- config.endpoint_tag
397
+ config
384
398
  );
385
399
  return response;
386
400
  } catch (err) {
@@ -391,7 +405,7 @@ function wrapAnthropic(client, config, shipper, heartbeat) {
391
405
  enforcement.model,
392
406
  startTime,
393
407
  enforcement,
394
- config.endpoint_tag
408
+ config
395
409
  );
396
410
  throw err;
397
411
  }
@@ -438,7 +452,7 @@ function wrapOpenAI(client, config, shipper, heartbeat) {
438
452
  outputTokens,
439
453
  enforcement,
440
454
  heartbeat,
441
- config.endpoint_tag
455
+ config
442
456
  );
443
457
  return response;
444
458
  } catch (err) {
@@ -449,7 +463,7 @@ function wrapOpenAI(client, config, shipper, heartbeat) {
449
463
  enforcement.model,
450
464
  startTime,
451
465
  enforcement,
452
- config.endpoint_tag
466
+ config
453
467
  );
454
468
  throw err;
455
469
  }
@@ -491,7 +505,8 @@ function protect(client, config) {
491
505
  const resolvedConfig = {
492
506
  ...config,
493
507
  mode,
494
- downgradeThreshold: config.downgradeThreshold ?? 0.8
508
+ downgradeThreshold: config.downgradeThreshold ?? 0.8,
509
+ dashboardUrl: config.dashboardUrl ?? "https://app.caplyr.com"
495
510
  };
496
511
  let shared = instances.get(resolvedConfig.apiKey);
497
512
  if (!shared) {
@@ -502,6 +517,10 @@ function protect(client, config) {
502
517
  instances.set(resolvedConfig.apiKey, shared);
503
518
  }
504
519
  const { shipper, heartbeat } = shared;
520
+ const sharedRef = shared;
521
+ resolvedConfig._onRequest = () => {
522
+ sharedRef.requestCount++;
523
+ };
505
524
  const provider = detectProvider(client);
506
525
  switch (provider) {
507
526
  case "anthropic":
@@ -527,10 +546,9 @@ function getState(apiKey) {
527
546
  budget_daily_used: heartbeat.budgetStatus.daily_used,
528
547
  budget_monthly_used: heartbeat.budgetStatus.monthly_used,
529
548
  kill_switch_active: heartbeat.budgetStatus.kill_switch_active,
530
- last_heartbeat: Date.now(),
549
+ last_heartbeat: heartbeat.lastHeartbeatAt,
531
550
  request_count: requestCount,
532
- total_cost: heartbeat.budgetStatus.monthly_used,
533
- total_savings: 0
551
+ total_cost: heartbeat.budgetStatus.monthly_used
534
552
  };
535
553
  }
536
554
  async function shutdown(apiKey) {
@@ -538,14 +556,16 @@ async function shutdown(apiKey) {
538
556
  const shared = instances.get(apiKey);
539
557
  if (shared) {
540
558
  shared.heartbeat.destroy();
541
- shared.shipper.destroy();
559
+ await shared.shipper.destroy();
542
560
  instances.delete(apiKey);
543
561
  }
544
562
  } else {
563
+ const promises = [];
545
564
  for (const [, shared] of instances) {
546
565
  shared.heartbeat.destroy();
547
- shared.shipper.destroy();
566
+ promises.push(shared.shipper.destroy());
548
567
  }
568
+ await Promise.all(promises);
549
569
  instances.clear();
550
570
  }
551
571
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "caplyr",
3
- "version": "0.1.3",
3
+ "version": "0.1.7",
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",