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 +44 -18
- package/dist/index.d.mts +8 -2
- package/dist/index.d.ts +8 -2
- package/dist/index.js +53 -33
- package/dist/index.mjs +53 -33
- package/package.json +1 -1
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
|
|
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_...",
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
64
|
-
protect(client, { apiKey: "..."
|
|
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` | — |
|
|
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` | `
|
|
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
|
-
|
|
105
|
-
|
|
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**: [
|
|
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 (
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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.
|
|
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,
|
|
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:
|
|
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,
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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 (
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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.
|
|
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,
|
|
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:
|
|
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,
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
}
|