caplyr 0.1.9 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -44
- package/dist/index.d.mts +143 -28
- package/dist/index.d.ts +143 -28
- package/dist/index.js +394 -261
- package/dist/index.mjs +394 -261
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -32,41 +32,40 @@ __export(index_exports, {
|
|
|
32
32
|
module.exports = __toCommonJS(index_exports);
|
|
33
33
|
|
|
34
34
|
// src/logger.ts
|
|
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
|
-
}
|
|
49
35
|
var LogShipper = class {
|
|
50
36
|
constructor(config) {
|
|
51
37
|
this.buffer = [];
|
|
52
38
|
this.timer = null;
|
|
53
|
-
this.endpoint = config.endpoint ?? "https://caplyr.com";
|
|
39
|
+
this.endpoint = config.endpoint ?? "https://api.caplyr.com";
|
|
54
40
|
this.apiKey = config.apiKey;
|
|
55
41
|
this.batchSize = config.batchSize ?? 10;
|
|
56
42
|
this.flushInterval = config.flushInterval ?? 3e4;
|
|
57
|
-
this.maxBufferSize = config.maxBufferSize ?? DEFAULT_MAX_BUFFER;
|
|
58
43
|
this.onError = config.onError;
|
|
59
44
|
this.timer = setInterval(() => this.flush(), this.flushInterval);
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
|
|
45
|
+
if (typeof process !== "undefined" && process.on) {
|
|
46
|
+
const flushAndExit = () => {
|
|
47
|
+
this.flush().finally(() => {
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
process.on("beforeExit", () => this.flush());
|
|
51
|
+
process.on("SIGTERM", flushAndExit);
|
|
52
|
+
process.on("SIGINT", flushAndExit);
|
|
53
|
+
}
|
|
63
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Add a log entry to the buffer.
|
|
57
|
+
* Auto-flushes when batch size is reached.
|
|
58
|
+
*/
|
|
64
59
|
push(log) {
|
|
65
60
|
this.buffer.push(log);
|
|
66
61
|
if (this.buffer.length >= this.batchSize) {
|
|
67
62
|
this.flush();
|
|
68
63
|
}
|
|
69
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Flush all buffered logs to the backend.
|
|
67
|
+
* Non-blocking — errors are swallowed and reported via onError.
|
|
68
|
+
*/
|
|
70
69
|
async flush() {
|
|
71
70
|
if (this.buffer.length === 0) return;
|
|
72
71
|
const batch = this.buffer.splice(0);
|
|
@@ -81,34 +80,30 @@ var LogShipper = class {
|
|
|
81
80
|
signal: AbortSignal.timeout(1e4)
|
|
82
81
|
});
|
|
83
82
|
if (!res.ok) {
|
|
84
|
-
this.
|
|
85
|
-
|
|
83
|
+
this.buffer.unshift(...batch);
|
|
84
|
+
throw new Error(`Ingest failed: ${res.status} ${res.statusText}`);
|
|
86
85
|
}
|
|
87
86
|
} catch (err) {
|
|
88
|
-
this.requeueFailed(batch);
|
|
89
87
|
this.onError?.(err instanceof Error ? err : new Error(String(err)));
|
|
90
88
|
}
|
|
91
89
|
}
|
|
92
90
|
/**
|
|
93
|
-
*
|
|
91
|
+
* Stop the periodic flush timer.
|
|
92
|
+
* Call this when tearing down the SDK.
|
|
94
93
|
*/
|
|
95
|
-
|
|
96
|
-
const available = this.maxBufferSize - this.buffer.length;
|
|
97
|
-
if (available <= 0) return;
|
|
98
|
-
const toKeep = batch.slice(-available);
|
|
99
|
-
this.buffer.unshift(...toKeep);
|
|
100
|
-
}
|
|
101
|
-
async destroy() {
|
|
94
|
+
destroy() {
|
|
102
95
|
if (this.timer) {
|
|
103
96
|
clearInterval(this.timer);
|
|
104
97
|
this.timer = null;
|
|
105
98
|
}
|
|
106
|
-
|
|
107
|
-
activeShippers.delete(this);
|
|
99
|
+
this.flush();
|
|
108
100
|
}
|
|
109
|
-
/** @deprecated Use destroy() instead */
|
|
110
101
|
async shutdown() {
|
|
111
|
-
|
|
102
|
+
if (this.timer) {
|
|
103
|
+
clearInterval(this.timer);
|
|
104
|
+
this.timer = null;
|
|
105
|
+
}
|
|
106
|
+
await this.flush();
|
|
112
107
|
}
|
|
113
108
|
};
|
|
114
109
|
|
|
@@ -118,6 +113,7 @@ var Heartbeat = class {
|
|
|
118
113
|
this.timer = null;
|
|
119
114
|
this.consecutiveFailures = 0;
|
|
120
115
|
this.maxFailuresBeforeDegraded = 3;
|
|
116
|
+
/** Current budget status from the backend */
|
|
121
117
|
this.budgetStatus = {
|
|
122
118
|
daily_used: 0,
|
|
123
119
|
daily_limit: null,
|
|
@@ -126,20 +122,25 @@ var Heartbeat = class {
|
|
|
126
122
|
status: "ACTIVE",
|
|
127
123
|
kill_switch_active: false
|
|
128
124
|
};
|
|
125
|
+
/** Current protection status */
|
|
129
126
|
this.status = "ACTIVE";
|
|
130
|
-
this.
|
|
131
|
-
this.endpoint = config.endpoint ?? "https://caplyr.com";
|
|
127
|
+
this.endpoint = config.endpoint ?? "https://api.caplyr.com";
|
|
132
128
|
this.apiKey = config.apiKey;
|
|
133
129
|
this.interval = config.heartbeatInterval ?? 6e4;
|
|
134
|
-
this.budget = config.budget;
|
|
135
130
|
this.onStatusChange = config.onStatusChange;
|
|
136
131
|
this.onError = config.onError;
|
|
137
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* Start the heartbeat loop.
|
|
135
|
+
* Immediately sends first heartbeat, then repeats on interval.
|
|
136
|
+
*/
|
|
138
137
|
start() {
|
|
139
138
|
this.beat();
|
|
140
139
|
this.timer = setInterval(() => this.beat(), this.interval);
|
|
141
|
-
if (this.timer.unref) this.timer.unref();
|
|
142
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* Send a single heartbeat and update local state.
|
|
143
|
+
*/
|
|
143
144
|
async beat() {
|
|
144
145
|
try {
|
|
145
146
|
const res = await fetch(`${this.endpoint}/api/heartbeat`, {
|
|
@@ -148,17 +149,15 @@ var Heartbeat = class {
|
|
|
148
149
|
"Content-Type": "application/json",
|
|
149
150
|
"Authorization": `Bearer ${this.apiKey}`
|
|
150
151
|
},
|
|
151
|
-
body: JSON.stringify({
|
|
152
|
-
timestamp: Date.now(),
|
|
153
|
-
...this.budget && { budget: this.budget }
|
|
154
|
-
}),
|
|
152
|
+
body: JSON.stringify({ timestamp: Date.now() }),
|
|
155
153
|
signal: AbortSignal.timeout(5e3)
|
|
156
154
|
});
|
|
157
|
-
if (!res.ok)
|
|
155
|
+
if (!res.ok) {
|
|
156
|
+
throw new Error(`Heartbeat failed: ${res.status}`);
|
|
157
|
+
}
|
|
158
158
|
const data = await res.json();
|
|
159
159
|
this.budgetStatus = data;
|
|
160
160
|
this.consecutiveFailures = 0;
|
|
161
|
-
this.lastHeartbeatAt = Date.now();
|
|
162
161
|
const newStatus = data.kill_switch_active ? "OFF" : data.status;
|
|
163
162
|
if (newStatus !== this.status) {
|
|
164
163
|
this.status = newStatus;
|
|
@@ -173,18 +172,32 @@ var Heartbeat = class {
|
|
|
173
172
|
}
|
|
174
173
|
}
|
|
175
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* Update local budget tracking (called after each request).
|
|
177
|
+
* This provides real-time budget awareness between heartbeats.
|
|
178
|
+
*/
|
|
176
179
|
trackSpend(cost) {
|
|
177
180
|
this.budgetStatus.daily_used += cost;
|
|
178
181
|
this.budgetStatus.monthly_used += cost;
|
|
179
182
|
}
|
|
183
|
+
/**
|
|
184
|
+
* Check if the monthly budget is exceeded.
|
|
185
|
+
*/
|
|
180
186
|
isMonthlyBudgetExceeded() {
|
|
181
187
|
if (this.budgetStatus.monthly_limit === null) return false;
|
|
182
188
|
return this.budgetStatus.monthly_used >= this.budgetStatus.monthly_limit;
|
|
183
189
|
}
|
|
190
|
+
/**
|
|
191
|
+
* Check if the daily budget is exceeded.
|
|
192
|
+
*/
|
|
184
193
|
isDailyBudgetExceeded() {
|
|
185
194
|
if (this.budgetStatus.daily_limit === null) return false;
|
|
186
195
|
return this.budgetStatus.daily_used >= this.budgetStatus.daily_limit;
|
|
187
196
|
}
|
|
197
|
+
/**
|
|
198
|
+
* Check if the downgrade threshold is reached.
|
|
199
|
+
* Returns true if usage exceeds the given threshold (0-1) of any budget.
|
|
200
|
+
*/
|
|
188
201
|
isDowngradeThresholdReached(threshold) {
|
|
189
202
|
if (this.budgetStatus.monthly_limit !== null && this.budgetStatus.monthly_used >= this.budgetStatus.monthly_limit * threshold) {
|
|
190
203
|
return true;
|
|
@@ -194,9 +207,15 @@ var Heartbeat = class {
|
|
|
194
207
|
}
|
|
195
208
|
return false;
|
|
196
209
|
}
|
|
210
|
+
/**
|
|
211
|
+
* Check if the kill switch is active.
|
|
212
|
+
*/
|
|
197
213
|
isKillSwitchActive() {
|
|
198
214
|
return this.budgetStatus.kill_switch_active;
|
|
199
215
|
}
|
|
216
|
+
/**
|
|
217
|
+
* Stop the heartbeat loop.
|
|
218
|
+
*/
|
|
200
219
|
destroy() {
|
|
201
220
|
if (this.timer) {
|
|
202
221
|
clearInterval(this.timer);
|
|
@@ -211,6 +230,7 @@ var MODEL_PRICING = {
|
|
|
211
230
|
"claude-opus-4-20250514": { input: 15, output: 75 },
|
|
212
231
|
"claude-sonnet-4-20250514": { input: 3, output: 15 },
|
|
213
232
|
"claude-haiku-4-5-20251001": { input: 0.8, output: 4 },
|
|
233
|
+
// Aliases
|
|
214
234
|
"claude-opus-4": { input: 15, output: 75 },
|
|
215
235
|
"claude-sonnet-4": { input: 3, output: 15 },
|
|
216
236
|
"claude-3-5-sonnet-20241022": { input: 3, output: 15 },
|
|
@@ -228,11 +248,13 @@ var MODEL_PRICING = {
|
|
|
228
248
|
"o3-mini": { input: 1.1, output: 4.4 }
|
|
229
249
|
};
|
|
230
250
|
var DEFAULT_FALLBACKS = {
|
|
251
|
+
// Anthropic downgrades
|
|
231
252
|
"claude-opus-4-20250514": "claude-sonnet-4-20250514",
|
|
232
253
|
"claude-opus-4": "claude-sonnet-4",
|
|
233
254
|
"claude-sonnet-4-20250514": "claude-haiku-4-5-20251001",
|
|
234
255
|
"claude-sonnet-4": "claude-haiku-4-5-20251001",
|
|
235
256
|
"claude-3-5-sonnet-20241022": "claude-haiku-4-5-20251001",
|
|
257
|
+
// OpenAI downgrades
|
|
236
258
|
"gpt-4o": "gpt-4o-mini",
|
|
237
259
|
"gpt-4o-2024-11-20": "gpt-4o-mini",
|
|
238
260
|
"gpt-4-turbo": "gpt-4o-mini",
|
|
@@ -252,7 +274,9 @@ function getDefaultFallback(model) {
|
|
|
252
274
|
}
|
|
253
275
|
function registerModel(model, pricing, fallback) {
|
|
254
276
|
MODEL_PRICING[model] = pricing;
|
|
255
|
-
if (fallback)
|
|
277
|
+
if (fallback) {
|
|
278
|
+
DEFAULT_FALLBACKS[model] = fallback;
|
|
279
|
+
}
|
|
256
280
|
}
|
|
257
281
|
function isKnownModel(model) {
|
|
258
282
|
return model in MODEL_PRICING;
|
|
@@ -261,182 +285,160 @@ function getModelPricing(model) {
|
|
|
261
285
|
return MODEL_PRICING[model] ?? null;
|
|
262
286
|
}
|
|
263
287
|
|
|
264
|
-
// src/
|
|
288
|
+
// src/interceptors/anthropic.ts
|
|
265
289
|
var idCounter = 0;
|
|
266
290
|
function generateId() {
|
|
267
|
-
return `caplyr_${Date.now()}_${
|
|
291
|
+
return `caplyr_${Date.now()}_${++idCounter}`;
|
|
268
292
|
}
|
|
269
|
-
function getNextResetTime(reason) {
|
|
270
|
-
const now = /* @__PURE__ */ new Date();
|
|
271
|
-
if (reason.includes("daily")) {
|
|
272
|
-
const tomorrow = new Date(now);
|
|
273
|
-
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
274
|
-
tomorrow.setHours(0, 0, 0, 0);
|
|
275
|
-
return tomorrow.toISOString();
|
|
276
|
-
}
|
|
277
|
-
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
|
278
|
-
return nextMonth.toISOString();
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// src/interceptors/enforce.ts
|
|
282
|
-
function enforcePreCall(model, config, heartbeat, shipper, provider, startTime) {
|
|
283
|
-
const dashboardUrl = `${config.dashboardUrl.replace(/\/dashboard\/?$/, "")}/dashboard`;
|
|
284
|
-
const downgradeThreshold = config.downgradeThreshold ?? 0.8;
|
|
285
|
-
config._onRequest?.();
|
|
286
|
-
if (heartbeat.isKillSwitchActive()) {
|
|
287
|
-
const reason = "kill_switch_active";
|
|
288
|
-
const blockError = {
|
|
289
|
-
code: "KILL_SWITCH_ACTIVE",
|
|
290
|
-
message: "Caplyr kill switch is active. All AI API calls are halted.",
|
|
291
|
-
budget_used: heartbeat.budgetStatus.monthly_used,
|
|
292
|
-
budget_limit: heartbeat.budgetStatus.monthly_limit ?? 0,
|
|
293
|
-
dashboard_url: dashboardUrl
|
|
294
|
-
};
|
|
295
|
-
pushBlockedLog(shipper, provider, model, startTime, reason, config.endpoint_tag);
|
|
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
|
-
});
|
|
303
|
-
throw Object.assign(new Error(blockError.message), { caplyr: blockError });
|
|
304
|
-
}
|
|
305
|
-
if (config.mode === "cost_protect") {
|
|
306
|
-
if (heartbeat.isMonthlyBudgetExceeded() || heartbeat.isDailyBudgetExceeded()) {
|
|
307
|
-
const reason = heartbeat.isDailyBudgetExceeded() ? "daily_budget_exceeded" : "monthly_budget_exceeded";
|
|
308
|
-
const blockError = {
|
|
309
|
-
code: "BUDGET_EXCEEDED",
|
|
310
|
-
message: `AI budget exceeded. ${reason.replace(/_/g, " ")}.`,
|
|
311
|
-
budget_used: heartbeat.budgetStatus.monthly_used,
|
|
312
|
-
budget_limit: heartbeat.budgetStatus.monthly_limit ?? 0,
|
|
313
|
-
retry_after: getNextResetTime(reason),
|
|
314
|
-
dashboard_url: dashboardUrl
|
|
315
|
-
};
|
|
316
|
-
pushBlockedLog(shipper, provider, model, startTime, reason, config.endpoint_tag);
|
|
317
|
-
throw Object.assign(new Error(blockError.message), { caplyr: blockError });
|
|
318
|
-
}
|
|
319
|
-
if (heartbeat.isDowngradeThresholdReached(downgradeThreshold)) {
|
|
320
|
-
const fallback = config.fallback ?? getDefaultFallback(model);
|
|
321
|
-
if (fallback && fallback !== model) {
|
|
322
|
-
config.onEnforcement?.({
|
|
323
|
-
type: "downgrade",
|
|
324
|
-
timestamp: Date.now(),
|
|
325
|
-
reason: `Budget at ${Math.round(downgradeThreshold * 100)}% \u2014 downgraded ${model} \u2192 ${fallback}`,
|
|
326
|
-
original_model: model,
|
|
327
|
-
fallback_model: fallback,
|
|
328
|
-
budget_used: heartbeat.budgetStatus.monthly_used,
|
|
329
|
-
budget_limit: heartbeat.budgetStatus.monthly_limit ?? 0,
|
|
330
|
-
estimated_savings: 0
|
|
331
|
-
});
|
|
332
|
-
return {
|
|
333
|
-
proceed: true,
|
|
334
|
-
model: fallback,
|
|
335
|
-
downgraded: true,
|
|
336
|
-
originalModel: model,
|
|
337
|
-
enforcementReason: "auto_downgrade_threshold"
|
|
338
|
-
};
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
return { proceed: true, model, downgraded: false };
|
|
343
|
-
}
|
|
344
|
-
function logSuccess(shipper, provider, model, startTime, inputTokens, outputTokens, enforcement, heartbeat, config) {
|
|
345
|
-
const cost = calculateCost(model, inputTokens, outputTokens);
|
|
346
|
-
heartbeat.trackSpend(cost);
|
|
347
|
-
shipper.push({
|
|
348
|
-
id: generateId(),
|
|
349
|
-
timestamp: startTime,
|
|
350
|
-
provider,
|
|
351
|
-
model,
|
|
352
|
-
input_tokens: inputTokens,
|
|
353
|
-
output_tokens: outputTokens,
|
|
354
|
-
cost,
|
|
355
|
-
latency_ms: Date.now() - startTime,
|
|
356
|
-
endpoint_tag: config.endpoint_tag,
|
|
357
|
-
downgraded: enforcement.downgraded,
|
|
358
|
-
original_model: enforcement.originalModel,
|
|
359
|
-
blocked: false,
|
|
360
|
-
enforcement_reason: enforcement.enforcementReason
|
|
361
|
-
});
|
|
362
|
-
return cost;
|
|
363
|
-
}
|
|
364
|
-
function logProviderError(shipper, provider, model, startTime, enforcement, config) {
|
|
365
|
-
shipper.push({
|
|
366
|
-
id: generateId(),
|
|
367
|
-
timestamp: startTime,
|
|
368
|
-
provider,
|
|
369
|
-
model,
|
|
370
|
-
input_tokens: 0,
|
|
371
|
-
output_tokens: 0,
|
|
372
|
-
cost: 0,
|
|
373
|
-
latency_ms: Date.now() - startTime,
|
|
374
|
-
endpoint_tag: config.endpoint_tag,
|
|
375
|
-
downgraded: enforcement.downgraded,
|
|
376
|
-
original_model: enforcement.originalModel,
|
|
377
|
-
blocked: false,
|
|
378
|
-
enforcement_reason: "provider_error"
|
|
379
|
-
});
|
|
380
|
-
}
|
|
381
|
-
function pushBlockedLog(shipper, provider, model, startTime, reason, endpointTag) {
|
|
382
|
-
shipper.push({
|
|
383
|
-
id: generateId(),
|
|
384
|
-
timestamp: startTime,
|
|
385
|
-
provider,
|
|
386
|
-
model,
|
|
387
|
-
input_tokens: 0,
|
|
388
|
-
output_tokens: 0,
|
|
389
|
-
cost: 0,
|
|
390
|
-
latency_ms: Date.now() - startTime,
|
|
391
|
-
endpoint_tag: endpointTag,
|
|
392
|
-
downgraded: false,
|
|
393
|
-
blocked: true,
|
|
394
|
-
enforcement_reason: reason
|
|
395
|
-
});
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
// src/interceptors/anthropic.ts
|
|
399
293
|
function wrapAnthropic(client, config, shipper, heartbeat) {
|
|
294
|
+
const downgradeThreshold = config.downgradeThreshold ?? 0.8;
|
|
295
|
+
const dashboardUrl = `${config.endpoint ?? "https://app.caplyr.com"}/dashboard`;
|
|
400
296
|
const messagesProxy = new Proxy(client.messages, {
|
|
401
297
|
get(target, prop, receiver) {
|
|
402
298
|
if (prop === "create") {
|
|
403
299
|
return async function caplyrInterceptedCreate(params, options) {
|
|
404
300
|
const startTime = Date.now();
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
301
|
+
let model = params.model;
|
|
302
|
+
let downgraded = false;
|
|
303
|
+
let originalModel;
|
|
304
|
+
let blocked = false;
|
|
305
|
+
let enforcementReason;
|
|
306
|
+
if (heartbeat.isKillSwitchActive()) {
|
|
307
|
+
blocked = true;
|
|
308
|
+
enforcementReason = "kill_switch_active";
|
|
309
|
+
const blockError = {
|
|
310
|
+
code: "KILL_SWITCH_ACTIVE",
|
|
311
|
+
message: "Caplyr kill switch is active. All AI API calls are halted.",
|
|
312
|
+
budget_used: heartbeat.budgetStatus.monthly_used,
|
|
313
|
+
budget_limit: heartbeat.budgetStatus.monthly_limit ?? 0,
|
|
314
|
+
dashboard_url: dashboardUrl
|
|
315
|
+
};
|
|
316
|
+
shipper.push({
|
|
317
|
+
id: generateId(),
|
|
318
|
+
timestamp: startTime,
|
|
319
|
+
provider: "anthropic",
|
|
320
|
+
model,
|
|
321
|
+
input_tokens: 0,
|
|
322
|
+
output_tokens: 0,
|
|
323
|
+
cost: 0,
|
|
324
|
+
latency_ms: Date.now() - startTime,
|
|
325
|
+
endpoint_tag: config.endpoint_tag,
|
|
326
|
+
downgraded: false,
|
|
327
|
+
blocked: true,
|
|
328
|
+
enforcement_reason: enforcementReason
|
|
329
|
+
});
|
|
330
|
+
if (config.mode === "alert_only") {
|
|
331
|
+
} else {
|
|
332
|
+
throw Object.assign(new Error(blockError.message), {
|
|
333
|
+
caplyr: blockError
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if (config.mode === "cost_protect") {
|
|
338
|
+
if (heartbeat.isMonthlyBudgetExceeded() || heartbeat.isDailyBudgetExceeded()) {
|
|
339
|
+
blocked = true;
|
|
340
|
+
enforcementReason = heartbeat.isDailyBudgetExceeded() ? "daily_budget_exceeded" : "monthly_budget_exceeded";
|
|
341
|
+
const blockError = {
|
|
342
|
+
code: "BUDGET_EXCEEDED",
|
|
343
|
+
message: `AI budget exceeded. ${enforcementReason.replace(/_/g, " ")}.`,
|
|
344
|
+
budget_used: heartbeat.budgetStatus.monthly_used,
|
|
345
|
+
budget_limit: heartbeat.budgetStatus.monthly_limit ?? 0,
|
|
346
|
+
retry_after: getNextResetTime(enforcementReason),
|
|
347
|
+
dashboard_url: dashboardUrl
|
|
348
|
+
};
|
|
349
|
+
shipper.push({
|
|
350
|
+
id: generateId(),
|
|
351
|
+
timestamp: startTime,
|
|
352
|
+
provider: "anthropic",
|
|
353
|
+
model,
|
|
354
|
+
input_tokens: 0,
|
|
355
|
+
output_tokens: 0,
|
|
356
|
+
cost: 0,
|
|
357
|
+
latency_ms: Date.now() - startTime,
|
|
358
|
+
endpoint_tag: config.endpoint_tag,
|
|
359
|
+
downgraded: false,
|
|
360
|
+
blocked: true,
|
|
361
|
+
enforcement_reason: enforcementReason
|
|
362
|
+
});
|
|
363
|
+
throw Object.assign(new Error(blockError.message), {
|
|
364
|
+
caplyr: blockError
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
if (heartbeat.isDowngradeThresholdReached(downgradeThreshold)) {
|
|
368
|
+
const fallback = config.fallback ?? getDefaultFallback(model);
|
|
369
|
+
if (fallback && fallback !== model) {
|
|
370
|
+
originalModel = model;
|
|
371
|
+
model = fallback;
|
|
372
|
+
downgraded = true;
|
|
373
|
+
enforcementReason = "auto_downgrade_threshold";
|
|
374
|
+
config.onEnforcement?.({
|
|
375
|
+
type: "downgrade",
|
|
376
|
+
timestamp: Date.now(),
|
|
377
|
+
reason: `Budget at ${Math.round(downgradeThreshold * 100)}% \u2014 downgraded ${originalModel} \u2192 ${model}`,
|
|
378
|
+
original_model: originalModel,
|
|
379
|
+
fallback_model: model,
|
|
380
|
+
budget_used: heartbeat.budgetStatus.monthly_used,
|
|
381
|
+
budget_limit: heartbeat.budgetStatus.monthly_limit ?? 0,
|
|
382
|
+
estimated_savings: 0
|
|
383
|
+
// Calculated after response
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
const requestParams = downgraded ? { ...params, model } : params;
|
|
414
389
|
try {
|
|
415
|
-
const response = await target.create.call(
|
|
390
|
+
const response = await target.create.call(
|
|
391
|
+
target,
|
|
392
|
+
requestParams,
|
|
393
|
+
options
|
|
394
|
+
);
|
|
395
|
+
const latency = Date.now() - startTime;
|
|
416
396
|
const inputTokens = response?.usage?.input_tokens ?? 0;
|
|
417
397
|
const outputTokens = response?.usage?.output_tokens ?? 0;
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
398
|
+
const cost = calculateCost(model, inputTokens, outputTokens);
|
|
399
|
+
heartbeat.trackSpend(cost);
|
|
400
|
+
let estimatedSavings = 0;
|
|
401
|
+
if (downgraded && originalModel) {
|
|
402
|
+
const originalCost = calculateCost(
|
|
403
|
+
originalModel,
|
|
404
|
+
inputTokens,
|
|
405
|
+
outputTokens
|
|
406
|
+
);
|
|
407
|
+
estimatedSavings = originalCost - cost;
|
|
408
|
+
}
|
|
409
|
+
shipper.push({
|
|
410
|
+
id: generateId(),
|
|
411
|
+
timestamp: startTime,
|
|
412
|
+
provider: "anthropic",
|
|
413
|
+
model,
|
|
414
|
+
input_tokens: inputTokens,
|
|
415
|
+
output_tokens: outputTokens,
|
|
416
|
+
cost,
|
|
417
|
+
latency_ms: latency,
|
|
418
|
+
endpoint_tag: config.endpoint_tag,
|
|
419
|
+
downgraded,
|
|
420
|
+
original_model: originalModel,
|
|
421
|
+
blocked: false,
|
|
422
|
+
enforcement_reason: enforcementReason
|
|
423
|
+
});
|
|
429
424
|
return response;
|
|
430
425
|
} catch (err) {
|
|
431
426
|
if (err?.caplyr) throw err;
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
427
|
+
shipper.push({
|
|
428
|
+
id: generateId(),
|
|
429
|
+
timestamp: startTime,
|
|
430
|
+
provider: "anthropic",
|
|
431
|
+
model,
|
|
432
|
+
input_tokens: 0,
|
|
433
|
+
output_tokens: 0,
|
|
434
|
+
cost: 0,
|
|
435
|
+
latency_ms: Date.now() - startTime,
|
|
436
|
+
endpoint_tag: config.endpoint_tag,
|
|
437
|
+
downgraded,
|
|
438
|
+
original_model: originalModel,
|
|
439
|
+
blocked: false,
|
|
440
|
+
enforcement_reason: "provider_error"
|
|
441
|
+
});
|
|
440
442
|
throw err;
|
|
441
443
|
}
|
|
442
444
|
};
|
|
@@ -446,55 +448,170 @@ function wrapAnthropic(client, config, shipper, heartbeat) {
|
|
|
446
448
|
});
|
|
447
449
|
return new Proxy(client, {
|
|
448
450
|
get(target, prop, receiver) {
|
|
449
|
-
if (prop === "messages")
|
|
451
|
+
if (prop === "messages") {
|
|
452
|
+
return messagesProxy;
|
|
453
|
+
}
|
|
450
454
|
return Reflect.get(target, prop, receiver);
|
|
451
455
|
}
|
|
452
456
|
});
|
|
453
457
|
}
|
|
458
|
+
function getNextResetTime(reason) {
|
|
459
|
+
const now = /* @__PURE__ */ new Date();
|
|
460
|
+
if (reason.includes("daily")) {
|
|
461
|
+
const tomorrow = new Date(now);
|
|
462
|
+
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
463
|
+
tomorrow.setHours(0, 0, 0, 0);
|
|
464
|
+
return tomorrow.toISOString();
|
|
465
|
+
}
|
|
466
|
+
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
|
467
|
+
return nextMonth.toISOString();
|
|
468
|
+
}
|
|
454
469
|
|
|
455
470
|
// src/interceptors/openai.ts
|
|
471
|
+
var idCounter2 = 0;
|
|
472
|
+
function generateId2() {
|
|
473
|
+
return `caplyr_${Date.now()}_${++idCounter2}`;
|
|
474
|
+
}
|
|
456
475
|
function wrapOpenAI(client, config, shipper, heartbeat) {
|
|
476
|
+
const downgradeThreshold = config.downgradeThreshold ?? 0.8;
|
|
477
|
+
const dashboardUrl = `${config.endpoint ?? "https://app.caplyr.com"}/dashboard`;
|
|
457
478
|
const completionsProxy = new Proxy(client.chat.completions, {
|
|
458
479
|
get(target, prop, receiver) {
|
|
459
480
|
if (prop === "create") {
|
|
460
481
|
return async function caplyrInterceptedCreate(params, options) {
|
|
461
482
|
const startTime = Date.now();
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
483
|
+
let model = params.model;
|
|
484
|
+
let downgraded = false;
|
|
485
|
+
let originalModel;
|
|
486
|
+
let blocked = false;
|
|
487
|
+
let enforcementReason;
|
|
488
|
+
if (heartbeat.isKillSwitchActive()) {
|
|
489
|
+
blocked = true;
|
|
490
|
+
enforcementReason = "kill_switch_active";
|
|
491
|
+
const blockError = {
|
|
492
|
+
code: "KILL_SWITCH_ACTIVE",
|
|
493
|
+
message: "Caplyr kill switch is active. All AI API calls are halted.",
|
|
494
|
+
budget_used: heartbeat.budgetStatus.monthly_used,
|
|
495
|
+
budget_limit: heartbeat.budgetStatus.monthly_limit ?? 0,
|
|
496
|
+
dashboard_url: dashboardUrl
|
|
497
|
+
};
|
|
498
|
+
shipper.push({
|
|
499
|
+
id: generateId2(),
|
|
500
|
+
timestamp: startTime,
|
|
501
|
+
provider: "openai",
|
|
502
|
+
model,
|
|
503
|
+
input_tokens: 0,
|
|
504
|
+
output_tokens: 0,
|
|
505
|
+
cost: 0,
|
|
506
|
+
latency_ms: Date.now() - startTime,
|
|
507
|
+
endpoint_tag: config.endpoint_tag,
|
|
508
|
+
downgraded: false,
|
|
509
|
+
blocked: true,
|
|
510
|
+
enforcement_reason: enforcementReason
|
|
511
|
+
});
|
|
512
|
+
if (config.mode === "alert_only") {
|
|
513
|
+
} else {
|
|
514
|
+
throw Object.assign(new Error(blockError.message), {
|
|
515
|
+
caplyr: blockError
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
if (config.mode === "cost_protect") {
|
|
520
|
+
if (heartbeat.isMonthlyBudgetExceeded() || heartbeat.isDailyBudgetExceeded()) {
|
|
521
|
+
blocked = true;
|
|
522
|
+
enforcementReason = heartbeat.isDailyBudgetExceeded() ? "daily_budget_exceeded" : "monthly_budget_exceeded";
|
|
523
|
+
const blockError = {
|
|
524
|
+
code: "BUDGET_EXCEEDED",
|
|
525
|
+
message: `AI budget exceeded. ${enforcementReason.replace(/_/g, " ")}.`,
|
|
526
|
+
budget_used: heartbeat.budgetStatus.monthly_used,
|
|
527
|
+
budget_limit: heartbeat.budgetStatus.monthly_limit ?? 0,
|
|
528
|
+
retry_after: getNextResetTime2(enforcementReason),
|
|
529
|
+
dashboard_url: dashboardUrl
|
|
530
|
+
};
|
|
531
|
+
shipper.push({
|
|
532
|
+
id: generateId2(),
|
|
533
|
+
timestamp: startTime,
|
|
534
|
+
provider: "openai",
|
|
535
|
+
model,
|
|
536
|
+
input_tokens: 0,
|
|
537
|
+
output_tokens: 0,
|
|
538
|
+
cost: 0,
|
|
539
|
+
latency_ms: Date.now() - startTime,
|
|
540
|
+
endpoint_tag: config.endpoint_tag,
|
|
541
|
+
downgraded: false,
|
|
542
|
+
blocked: true,
|
|
543
|
+
enforcement_reason: enforcementReason
|
|
544
|
+
});
|
|
545
|
+
throw Object.assign(new Error(blockError.message), {
|
|
546
|
+
caplyr: blockError
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
if (heartbeat.isDowngradeThresholdReached(downgradeThreshold)) {
|
|
550
|
+
const fallback = config.fallback ?? getDefaultFallback(model);
|
|
551
|
+
if (fallback && fallback !== model) {
|
|
552
|
+
originalModel = model;
|
|
553
|
+
model = fallback;
|
|
554
|
+
downgraded = true;
|
|
555
|
+
enforcementReason = "auto_downgrade_threshold";
|
|
556
|
+
config.onEnforcement?.({
|
|
557
|
+
type: "downgrade",
|
|
558
|
+
timestamp: Date.now(),
|
|
559
|
+
reason: `Budget at ${Math.round(downgradeThreshold * 100)}% \u2014 downgraded ${originalModel} \u2192 ${model}`,
|
|
560
|
+
original_model: originalModel,
|
|
561
|
+
fallback_model: model,
|
|
562
|
+
budget_used: heartbeat.budgetStatus.monthly_used,
|
|
563
|
+
budget_limit: heartbeat.budgetStatus.monthly_limit ?? 0,
|
|
564
|
+
estimated_savings: 0
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
const requestParams = downgraded ? { ...params, model } : params;
|
|
471
570
|
try {
|
|
472
|
-
const response = await target.create.call(
|
|
571
|
+
const response = await target.create.call(
|
|
572
|
+
target,
|
|
573
|
+
requestParams,
|
|
574
|
+
options
|
|
575
|
+
);
|
|
576
|
+
const latency = Date.now() - startTime;
|
|
473
577
|
const usage = response?.usage;
|
|
474
578
|
const inputTokens = usage?.prompt_tokens ?? 0;
|
|
475
579
|
const outputTokens = usage?.completion_tokens ?? 0;
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
startTime,
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
580
|
+
const cost = calculateCost(model, inputTokens, outputTokens);
|
|
581
|
+
heartbeat.trackSpend(cost);
|
|
582
|
+
shipper.push({
|
|
583
|
+
id: generateId2(),
|
|
584
|
+
timestamp: startTime,
|
|
585
|
+
provider: "openai",
|
|
586
|
+
model,
|
|
587
|
+
input_tokens: inputTokens,
|
|
588
|
+
output_tokens: outputTokens,
|
|
589
|
+
cost,
|
|
590
|
+
latency_ms: latency,
|
|
591
|
+
endpoint_tag: config.endpoint_tag,
|
|
592
|
+
downgraded,
|
|
593
|
+
original_model: originalModel,
|
|
594
|
+
blocked: false,
|
|
595
|
+
enforcement_reason: enforcementReason
|
|
596
|
+
});
|
|
487
597
|
return response;
|
|
488
598
|
} catch (err) {
|
|
489
599
|
if (err?.caplyr) throw err;
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
600
|
+
shipper.push({
|
|
601
|
+
id: generateId2(),
|
|
602
|
+
timestamp: startTime,
|
|
603
|
+
provider: "openai",
|
|
604
|
+
model,
|
|
605
|
+
input_tokens: 0,
|
|
606
|
+
output_tokens: 0,
|
|
607
|
+
cost: 0,
|
|
608
|
+
latency_ms: Date.now() - startTime,
|
|
609
|
+
endpoint_tag: config.endpoint_tag,
|
|
610
|
+
downgraded,
|
|
611
|
+
original_model: originalModel,
|
|
612
|
+
blocked: false,
|
|
613
|
+
enforcement_reason: "provider_error"
|
|
614
|
+
});
|
|
498
615
|
throw err;
|
|
499
616
|
}
|
|
500
617
|
};
|
|
@@ -504,17 +621,32 @@ function wrapOpenAI(client, config, shipper, heartbeat) {
|
|
|
504
621
|
});
|
|
505
622
|
const chatProxy = new Proxy(client.chat, {
|
|
506
623
|
get(target, prop, receiver) {
|
|
507
|
-
if (prop === "completions")
|
|
624
|
+
if (prop === "completions") {
|
|
625
|
+
return completionsProxy;
|
|
626
|
+
}
|
|
508
627
|
return Reflect.get(target, prop, receiver);
|
|
509
628
|
}
|
|
510
629
|
});
|
|
511
630
|
return new Proxy(client, {
|
|
512
631
|
get(target, prop, receiver) {
|
|
513
|
-
if (prop === "chat")
|
|
632
|
+
if (prop === "chat") {
|
|
633
|
+
return chatProxy;
|
|
634
|
+
}
|
|
514
635
|
return Reflect.get(target, prop, receiver);
|
|
515
636
|
}
|
|
516
637
|
});
|
|
517
638
|
}
|
|
639
|
+
function getNextResetTime2(reason) {
|
|
640
|
+
const now = /* @__PURE__ */ new Date();
|
|
641
|
+
if (reason.includes("daily")) {
|
|
642
|
+
const tomorrow = new Date(now);
|
|
643
|
+
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
644
|
+
tomorrow.setHours(0, 0, 0, 0);
|
|
645
|
+
return tomorrow.toISOString();
|
|
646
|
+
}
|
|
647
|
+
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
|
648
|
+
return nextMonth.toISOString();
|
|
649
|
+
}
|
|
518
650
|
|
|
519
651
|
// src/protect.ts
|
|
520
652
|
function detectProvider(client) {
|
|
@@ -529,28 +661,27 @@ function detectProvider(client) {
|
|
|
529
661
|
var instances = /* @__PURE__ */ new Map();
|
|
530
662
|
function protect(client, config) {
|
|
531
663
|
if (!config.apiKey) {
|
|
532
|
-
throw new Error(
|
|
664
|
+
throw new Error(
|
|
665
|
+
"Caplyr: apiKey is required. Get yours at https://app.caplyr.com"
|
|
666
|
+
);
|
|
533
667
|
}
|
|
534
|
-
const mode = config.mode ?? (config.budget ? "cost_protect" : "alert_only");
|
|
535
668
|
const resolvedConfig = {
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
669
|
+
mode: config.budget && !config.mode ? "cost_protect" : "alert_only",
|
|
670
|
+
downgradeThreshold: 0.8,
|
|
671
|
+
batchSize: 10,
|
|
672
|
+
flushInterval: 3e4,
|
|
673
|
+
heartbeatInterval: 6e4,
|
|
674
|
+
...config
|
|
540
675
|
};
|
|
541
676
|
let shared = instances.get(resolvedConfig.apiKey);
|
|
542
677
|
if (!shared) {
|
|
543
678
|
const shipper2 = new LogShipper(resolvedConfig);
|
|
544
679
|
const heartbeat2 = new Heartbeat(resolvedConfig);
|
|
545
680
|
heartbeat2.start();
|
|
546
|
-
shared = { shipper: shipper2, heartbeat: heartbeat2
|
|
681
|
+
shared = { shipper: shipper2, heartbeat: heartbeat2 };
|
|
547
682
|
instances.set(resolvedConfig.apiKey, shared);
|
|
548
683
|
}
|
|
549
684
|
const { shipper, heartbeat } = shared;
|
|
550
|
-
const sharedRef = shared;
|
|
551
|
-
resolvedConfig._onRequest = () => {
|
|
552
|
-
sharedRef.requestCount++;
|
|
553
|
-
};
|
|
554
685
|
const provider = detectProvider(client);
|
|
555
686
|
switch (provider) {
|
|
556
687
|
case "anthropic":
|
|
@@ -559,26 +690,30 @@ function protect(client, config) {
|
|
|
559
690
|
return wrapOpenAI(client, resolvedConfig, shipper, heartbeat);
|
|
560
691
|
default:
|
|
561
692
|
throw new Error(
|
|
562
|
-
|
|
693
|
+
`Caplyr: Unrecognized AI client. Supported providers: Anthropic, OpenAI. Make sure you're passing a client instance (e.g., new Anthropic() or new OpenAI()).`
|
|
563
694
|
);
|
|
564
695
|
}
|
|
565
696
|
}
|
|
566
697
|
function getStatus(apiKey) {
|
|
567
|
-
|
|
698
|
+
const shared = instances.get(apiKey);
|
|
699
|
+
return shared?.heartbeat.status ?? "OFF";
|
|
568
700
|
}
|
|
569
701
|
function getState(apiKey) {
|
|
570
702
|
const shared = instances.get(apiKey);
|
|
571
703
|
if (!shared) return null;
|
|
572
|
-
const { heartbeat
|
|
704
|
+
const { heartbeat } = shared;
|
|
573
705
|
return {
|
|
574
706
|
status: heartbeat.status,
|
|
575
|
-
mode,
|
|
707
|
+
mode: "alert_only",
|
|
708
|
+
// Will be stored properly in next iteration
|
|
576
709
|
budget_daily_used: heartbeat.budgetStatus.daily_used,
|
|
577
710
|
budget_monthly_used: heartbeat.budgetStatus.monthly_used,
|
|
578
711
|
kill_switch_active: heartbeat.budgetStatus.kill_switch_active,
|
|
579
|
-
last_heartbeat:
|
|
580
|
-
request_count:
|
|
581
|
-
|
|
712
|
+
last_heartbeat: Date.now(),
|
|
713
|
+
request_count: 0,
|
|
714
|
+
// Will be tracked in next iteration
|
|
715
|
+
total_cost: heartbeat.budgetStatus.monthly_used,
|
|
716
|
+
total_savings: 0
|
|
582
717
|
};
|
|
583
718
|
}
|
|
584
719
|
async function shutdown(apiKey) {
|
|
@@ -586,16 +721,14 @@ async function shutdown(apiKey) {
|
|
|
586
721
|
const shared = instances.get(apiKey);
|
|
587
722
|
if (shared) {
|
|
588
723
|
shared.heartbeat.destroy();
|
|
589
|
-
|
|
724
|
+
shared.shipper.destroy();
|
|
590
725
|
instances.delete(apiKey);
|
|
591
726
|
}
|
|
592
727
|
} else {
|
|
593
|
-
const
|
|
594
|
-
for (const [, shared] of instances) {
|
|
728
|
+
for (const [key, shared] of instances) {
|
|
595
729
|
shared.heartbeat.destroy();
|
|
596
|
-
|
|
730
|
+
shared.shipper.destroy();
|
|
597
731
|
}
|
|
598
|
-
await Promise.all(promises);
|
|
599
732
|
instances.clear();
|
|
600
733
|
}
|
|
601
734
|
}
|