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 +44 -18
- package/dist/index.d.mts +8 -2
- package/dist/index.d.ts +8 -2
- package/dist/index.js +60 -43
- package/dist/index.mjs +60 -43
- 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 = {
|
|
@@ -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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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,
|
|
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:
|
|
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,
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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 (
|
|
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 = {
|
|
@@ -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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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,
|
|
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:
|
|
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,
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
}
|