caplyr 0.1.9 → 0.2.1
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 +411 -260
- package/dist/index.mjs +411 -260
- 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,38 @@ 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.lastHeartbeatAt = null;
|
|
131
127
|
this.endpoint = config.endpoint ?? "https://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
|
+
* Apply local budget limits from config.
|
|
135
|
+
* These act as client-side enforcement even if the server
|
|
136
|
+
* doesn't return limits (e.g. during heartbeat failures).
|
|
137
|
+
*/
|
|
138
|
+
applyLocalLimits(budget) {
|
|
139
|
+
if (budget.daily !== void 0) {
|
|
140
|
+
this.budgetStatus.daily_limit = budget.daily;
|
|
141
|
+
}
|
|
142
|
+
if (budget.monthly !== void 0) {
|
|
143
|
+
this.budgetStatus.monthly_limit = budget.monthly;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Start the heartbeat loop.
|
|
148
|
+
* Immediately sends first heartbeat, then repeats on interval.
|
|
149
|
+
*/
|
|
138
150
|
start() {
|
|
139
151
|
this.beat();
|
|
140
152
|
this.timer = setInterval(() => this.beat(), this.interval);
|
|
141
|
-
if (this.timer.unref) this.timer.unref();
|
|
142
153
|
}
|
|
154
|
+
/**
|
|
155
|
+
* Send a single heartbeat and update local state.
|
|
156
|
+
*/
|
|
143
157
|
async beat() {
|
|
144
158
|
try {
|
|
145
159
|
const res = await fetch(`${this.endpoint}/api/heartbeat`, {
|
|
@@ -148,17 +162,15 @@ var Heartbeat = class {
|
|
|
148
162
|
"Content-Type": "application/json",
|
|
149
163
|
"Authorization": `Bearer ${this.apiKey}`
|
|
150
164
|
},
|
|
151
|
-
body: JSON.stringify({
|
|
152
|
-
timestamp: Date.now(),
|
|
153
|
-
...this.budget && { budget: this.budget }
|
|
154
|
-
}),
|
|
165
|
+
body: JSON.stringify({ timestamp: Date.now() }),
|
|
155
166
|
signal: AbortSignal.timeout(5e3)
|
|
156
167
|
});
|
|
157
|
-
if (!res.ok)
|
|
168
|
+
if (!res.ok) {
|
|
169
|
+
throw new Error(`Heartbeat failed: ${res.status}`);
|
|
170
|
+
}
|
|
158
171
|
const data = await res.json();
|
|
159
172
|
this.budgetStatus = data;
|
|
160
173
|
this.consecutiveFailures = 0;
|
|
161
|
-
this.lastHeartbeatAt = Date.now();
|
|
162
174
|
const newStatus = data.kill_switch_active ? "OFF" : data.status;
|
|
163
175
|
if (newStatus !== this.status) {
|
|
164
176
|
this.status = newStatus;
|
|
@@ -173,18 +185,32 @@ var Heartbeat = class {
|
|
|
173
185
|
}
|
|
174
186
|
}
|
|
175
187
|
}
|
|
188
|
+
/**
|
|
189
|
+
* Update local budget tracking (called after each request).
|
|
190
|
+
* This provides real-time budget awareness between heartbeats.
|
|
191
|
+
*/
|
|
176
192
|
trackSpend(cost) {
|
|
177
193
|
this.budgetStatus.daily_used += cost;
|
|
178
194
|
this.budgetStatus.monthly_used += cost;
|
|
179
195
|
}
|
|
196
|
+
/**
|
|
197
|
+
* Check if the monthly budget is exceeded.
|
|
198
|
+
*/
|
|
180
199
|
isMonthlyBudgetExceeded() {
|
|
181
200
|
if (this.budgetStatus.monthly_limit === null) return false;
|
|
182
201
|
return this.budgetStatus.monthly_used >= this.budgetStatus.monthly_limit;
|
|
183
202
|
}
|
|
203
|
+
/**
|
|
204
|
+
* Check if the daily budget is exceeded.
|
|
205
|
+
*/
|
|
184
206
|
isDailyBudgetExceeded() {
|
|
185
207
|
if (this.budgetStatus.daily_limit === null) return false;
|
|
186
208
|
return this.budgetStatus.daily_used >= this.budgetStatus.daily_limit;
|
|
187
209
|
}
|
|
210
|
+
/**
|
|
211
|
+
* Check if the downgrade threshold is reached.
|
|
212
|
+
* Returns true if usage exceeds the given threshold (0-1) of any budget.
|
|
213
|
+
*/
|
|
188
214
|
isDowngradeThresholdReached(threshold) {
|
|
189
215
|
if (this.budgetStatus.monthly_limit !== null && this.budgetStatus.monthly_used >= this.budgetStatus.monthly_limit * threshold) {
|
|
190
216
|
return true;
|
|
@@ -194,9 +220,15 @@ var Heartbeat = class {
|
|
|
194
220
|
}
|
|
195
221
|
return false;
|
|
196
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* Check if the kill switch is active.
|
|
225
|
+
*/
|
|
197
226
|
isKillSwitchActive() {
|
|
198
227
|
return this.budgetStatus.kill_switch_active;
|
|
199
228
|
}
|
|
229
|
+
/**
|
|
230
|
+
* Stop the heartbeat loop.
|
|
231
|
+
*/
|
|
200
232
|
destroy() {
|
|
201
233
|
if (this.timer) {
|
|
202
234
|
clearInterval(this.timer);
|
|
@@ -211,6 +243,7 @@ var MODEL_PRICING = {
|
|
|
211
243
|
"claude-opus-4-20250514": { input: 15, output: 75 },
|
|
212
244
|
"claude-sonnet-4-20250514": { input: 3, output: 15 },
|
|
213
245
|
"claude-haiku-4-5-20251001": { input: 0.8, output: 4 },
|
|
246
|
+
// Aliases
|
|
214
247
|
"claude-opus-4": { input: 15, output: 75 },
|
|
215
248
|
"claude-sonnet-4": { input: 3, output: 15 },
|
|
216
249
|
"claude-3-5-sonnet-20241022": { input: 3, output: 15 },
|
|
@@ -228,11 +261,13 @@ var MODEL_PRICING = {
|
|
|
228
261
|
"o3-mini": { input: 1.1, output: 4.4 }
|
|
229
262
|
};
|
|
230
263
|
var DEFAULT_FALLBACKS = {
|
|
264
|
+
// Anthropic downgrades
|
|
231
265
|
"claude-opus-4-20250514": "claude-sonnet-4-20250514",
|
|
232
266
|
"claude-opus-4": "claude-sonnet-4",
|
|
233
267
|
"claude-sonnet-4-20250514": "claude-haiku-4-5-20251001",
|
|
234
268
|
"claude-sonnet-4": "claude-haiku-4-5-20251001",
|
|
235
269
|
"claude-3-5-sonnet-20241022": "claude-haiku-4-5-20251001",
|
|
270
|
+
// OpenAI downgrades
|
|
236
271
|
"gpt-4o": "gpt-4o-mini",
|
|
237
272
|
"gpt-4o-2024-11-20": "gpt-4o-mini",
|
|
238
273
|
"gpt-4-turbo": "gpt-4o-mini",
|
|
@@ -252,7 +287,9 @@ function getDefaultFallback(model) {
|
|
|
252
287
|
}
|
|
253
288
|
function registerModel(model, pricing, fallback) {
|
|
254
289
|
MODEL_PRICING[model] = pricing;
|
|
255
|
-
if (fallback)
|
|
290
|
+
if (fallback) {
|
|
291
|
+
DEFAULT_FALLBACKS[model] = fallback;
|
|
292
|
+
}
|
|
256
293
|
}
|
|
257
294
|
function isKnownModel(model) {
|
|
258
295
|
return model in MODEL_PRICING;
|
|
@@ -261,182 +298,160 @@ function getModelPricing(model) {
|
|
|
261
298
|
return MODEL_PRICING[model] ?? null;
|
|
262
299
|
}
|
|
263
300
|
|
|
264
|
-
// src/
|
|
301
|
+
// src/interceptors/anthropic.ts
|
|
265
302
|
var idCounter = 0;
|
|
266
303
|
function generateId() {
|
|
267
|
-
return `caplyr_${Date.now()}_${
|
|
268
|
-
}
|
|
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;
|
|
304
|
+
return `caplyr_${Date.now()}_${++idCounter}`;
|
|
363
305
|
}
|
|
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
306
|
function wrapAnthropic(client, config, shipper, heartbeat) {
|
|
307
|
+
const downgradeThreshold = config.downgradeThreshold ?? 0.8;
|
|
308
|
+
const dashboardUrl = `${config.endpoint ?? "https://app.caplyr.com"}/dashboard`;
|
|
400
309
|
const messagesProxy = new Proxy(client.messages, {
|
|
401
310
|
get(target, prop, receiver) {
|
|
402
311
|
if (prop === "create") {
|
|
403
312
|
return async function caplyrInterceptedCreate(params, options) {
|
|
404
313
|
const startTime = Date.now();
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
314
|
+
let model = params.model;
|
|
315
|
+
let downgraded = false;
|
|
316
|
+
let originalModel;
|
|
317
|
+
let blocked = false;
|
|
318
|
+
let enforcementReason;
|
|
319
|
+
if (heartbeat.isKillSwitchActive()) {
|
|
320
|
+
blocked = true;
|
|
321
|
+
enforcementReason = "kill_switch_active";
|
|
322
|
+
const blockError = {
|
|
323
|
+
code: "KILL_SWITCH_ACTIVE",
|
|
324
|
+
message: "Caplyr kill switch is active. All AI API calls are halted.",
|
|
325
|
+
budget_used: heartbeat.budgetStatus.monthly_used,
|
|
326
|
+
budget_limit: heartbeat.budgetStatus.monthly_limit ?? 0,
|
|
327
|
+
dashboard_url: dashboardUrl
|
|
328
|
+
};
|
|
329
|
+
shipper.push({
|
|
330
|
+
id: generateId(),
|
|
331
|
+
timestamp: startTime,
|
|
332
|
+
provider: "anthropic",
|
|
333
|
+
model,
|
|
334
|
+
input_tokens: 0,
|
|
335
|
+
output_tokens: 0,
|
|
336
|
+
cost: 0,
|
|
337
|
+
latency_ms: Date.now() - startTime,
|
|
338
|
+
endpoint_tag: config.endpoint_tag,
|
|
339
|
+
downgraded: false,
|
|
340
|
+
blocked: true,
|
|
341
|
+
enforcement_reason: enforcementReason
|
|
342
|
+
});
|
|
343
|
+
if (config.mode === "alert_only") {
|
|
344
|
+
} else {
|
|
345
|
+
throw Object.assign(new Error(blockError.message), {
|
|
346
|
+
caplyr: blockError
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (config.mode === "cost_protect") {
|
|
351
|
+
if (heartbeat.isMonthlyBudgetExceeded() || heartbeat.isDailyBudgetExceeded()) {
|
|
352
|
+
blocked = true;
|
|
353
|
+
enforcementReason = heartbeat.isDailyBudgetExceeded() ? "daily_budget_exceeded" : "monthly_budget_exceeded";
|
|
354
|
+
const blockError = {
|
|
355
|
+
code: "BUDGET_EXCEEDED",
|
|
356
|
+
message: `AI budget exceeded. ${enforcementReason.replace(/_/g, " ")}.`,
|
|
357
|
+
budget_used: heartbeat.budgetStatus.monthly_used,
|
|
358
|
+
budget_limit: heartbeat.budgetStatus.monthly_limit ?? 0,
|
|
359
|
+
retry_after: getNextResetTime(enforcementReason),
|
|
360
|
+
dashboard_url: dashboardUrl
|
|
361
|
+
};
|
|
362
|
+
shipper.push({
|
|
363
|
+
id: generateId(),
|
|
364
|
+
timestamp: startTime,
|
|
365
|
+
provider: "anthropic",
|
|
366
|
+
model,
|
|
367
|
+
input_tokens: 0,
|
|
368
|
+
output_tokens: 0,
|
|
369
|
+
cost: 0,
|
|
370
|
+
latency_ms: Date.now() - startTime,
|
|
371
|
+
endpoint_tag: config.endpoint_tag,
|
|
372
|
+
downgraded: false,
|
|
373
|
+
blocked: true,
|
|
374
|
+
enforcement_reason: enforcementReason
|
|
375
|
+
});
|
|
376
|
+
throw Object.assign(new Error(blockError.message), {
|
|
377
|
+
caplyr: blockError
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
if (heartbeat.isDowngradeThresholdReached(downgradeThreshold)) {
|
|
381
|
+
const fallback = config.fallback ?? getDefaultFallback(model);
|
|
382
|
+
if (fallback && fallback !== model) {
|
|
383
|
+
originalModel = model;
|
|
384
|
+
model = fallback;
|
|
385
|
+
downgraded = true;
|
|
386
|
+
enforcementReason = "auto_downgrade_threshold";
|
|
387
|
+
config.onEnforcement?.({
|
|
388
|
+
type: "downgrade",
|
|
389
|
+
timestamp: Date.now(),
|
|
390
|
+
reason: `Budget at ${Math.round(downgradeThreshold * 100)}% \u2014 downgraded ${originalModel} \u2192 ${model}`,
|
|
391
|
+
original_model: originalModel,
|
|
392
|
+
fallback_model: model,
|
|
393
|
+
budget_used: heartbeat.budgetStatus.monthly_used,
|
|
394
|
+
budget_limit: heartbeat.budgetStatus.monthly_limit ?? 0,
|
|
395
|
+
estimated_savings: 0
|
|
396
|
+
// Calculated after response
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
const requestParams = downgraded ? { ...params, model } : params;
|
|
414
402
|
try {
|
|
415
|
-
const response = await target.create.call(
|
|
403
|
+
const response = await target.create.call(
|
|
404
|
+
target,
|
|
405
|
+
requestParams,
|
|
406
|
+
options
|
|
407
|
+
);
|
|
408
|
+
const latency = Date.now() - startTime;
|
|
416
409
|
const inputTokens = response?.usage?.input_tokens ?? 0;
|
|
417
410
|
const outputTokens = response?.usage?.output_tokens ?? 0;
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
411
|
+
const cost = calculateCost(model, inputTokens, outputTokens);
|
|
412
|
+
heartbeat.trackSpend(cost);
|
|
413
|
+
let estimatedSavings = 0;
|
|
414
|
+
if (downgraded && originalModel) {
|
|
415
|
+
const originalCost = calculateCost(
|
|
416
|
+
originalModel,
|
|
417
|
+
inputTokens,
|
|
418
|
+
outputTokens
|
|
419
|
+
);
|
|
420
|
+
estimatedSavings = originalCost - cost;
|
|
421
|
+
}
|
|
422
|
+
shipper.push({
|
|
423
|
+
id: generateId(),
|
|
424
|
+
timestamp: startTime,
|
|
425
|
+
provider: "anthropic",
|
|
426
|
+
model,
|
|
427
|
+
input_tokens: inputTokens,
|
|
428
|
+
output_tokens: outputTokens,
|
|
429
|
+
cost,
|
|
430
|
+
latency_ms: latency,
|
|
431
|
+
endpoint_tag: config.endpoint_tag,
|
|
432
|
+
downgraded,
|
|
433
|
+
original_model: originalModel,
|
|
434
|
+
blocked: false,
|
|
435
|
+
enforcement_reason: enforcementReason
|
|
436
|
+
});
|
|
429
437
|
return response;
|
|
430
438
|
} catch (err) {
|
|
431
439
|
if (err?.caplyr) throw err;
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
+
shipper.push({
|
|
441
|
+
id: generateId(),
|
|
442
|
+
timestamp: startTime,
|
|
443
|
+
provider: "anthropic",
|
|
444
|
+
model,
|
|
445
|
+
input_tokens: 0,
|
|
446
|
+
output_tokens: 0,
|
|
447
|
+
cost: 0,
|
|
448
|
+
latency_ms: Date.now() - startTime,
|
|
449
|
+
endpoint_tag: config.endpoint_tag,
|
|
450
|
+
downgraded,
|
|
451
|
+
original_model: originalModel,
|
|
452
|
+
blocked: false,
|
|
453
|
+
enforcement_reason: "provider_error"
|
|
454
|
+
});
|
|
440
455
|
throw err;
|
|
441
456
|
}
|
|
442
457
|
};
|
|
@@ -446,55 +461,170 @@ function wrapAnthropic(client, config, shipper, heartbeat) {
|
|
|
446
461
|
});
|
|
447
462
|
return new Proxy(client, {
|
|
448
463
|
get(target, prop, receiver) {
|
|
449
|
-
if (prop === "messages")
|
|
464
|
+
if (prop === "messages") {
|
|
465
|
+
return messagesProxy;
|
|
466
|
+
}
|
|
450
467
|
return Reflect.get(target, prop, receiver);
|
|
451
468
|
}
|
|
452
469
|
});
|
|
453
470
|
}
|
|
471
|
+
function getNextResetTime(reason) {
|
|
472
|
+
const now = /* @__PURE__ */ new Date();
|
|
473
|
+
if (reason.includes("daily")) {
|
|
474
|
+
const tomorrow = new Date(now);
|
|
475
|
+
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
476
|
+
tomorrow.setHours(0, 0, 0, 0);
|
|
477
|
+
return tomorrow.toISOString();
|
|
478
|
+
}
|
|
479
|
+
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
|
480
|
+
return nextMonth.toISOString();
|
|
481
|
+
}
|
|
454
482
|
|
|
455
483
|
// src/interceptors/openai.ts
|
|
484
|
+
var idCounter2 = 0;
|
|
485
|
+
function generateId2() {
|
|
486
|
+
return `caplyr_${Date.now()}_${++idCounter2}`;
|
|
487
|
+
}
|
|
456
488
|
function wrapOpenAI(client, config, shipper, heartbeat) {
|
|
489
|
+
const downgradeThreshold = config.downgradeThreshold ?? 0.8;
|
|
490
|
+
const dashboardUrl = `${config.endpoint ?? "https://app.caplyr.com"}/dashboard`;
|
|
457
491
|
const completionsProxy = new Proxy(client.chat.completions, {
|
|
458
492
|
get(target, prop, receiver) {
|
|
459
493
|
if (prop === "create") {
|
|
460
494
|
return async function caplyrInterceptedCreate(params, options) {
|
|
461
495
|
const startTime = Date.now();
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
496
|
+
let model = params.model;
|
|
497
|
+
let downgraded = false;
|
|
498
|
+
let originalModel;
|
|
499
|
+
let blocked = false;
|
|
500
|
+
let enforcementReason;
|
|
501
|
+
if (heartbeat.isKillSwitchActive()) {
|
|
502
|
+
blocked = true;
|
|
503
|
+
enforcementReason = "kill_switch_active";
|
|
504
|
+
const blockError = {
|
|
505
|
+
code: "KILL_SWITCH_ACTIVE",
|
|
506
|
+
message: "Caplyr kill switch is active. All AI API calls are halted.",
|
|
507
|
+
budget_used: heartbeat.budgetStatus.monthly_used,
|
|
508
|
+
budget_limit: heartbeat.budgetStatus.monthly_limit ?? 0,
|
|
509
|
+
dashboard_url: dashboardUrl
|
|
510
|
+
};
|
|
511
|
+
shipper.push({
|
|
512
|
+
id: generateId2(),
|
|
513
|
+
timestamp: startTime,
|
|
514
|
+
provider: "openai",
|
|
515
|
+
model,
|
|
516
|
+
input_tokens: 0,
|
|
517
|
+
output_tokens: 0,
|
|
518
|
+
cost: 0,
|
|
519
|
+
latency_ms: Date.now() - startTime,
|
|
520
|
+
endpoint_tag: config.endpoint_tag,
|
|
521
|
+
downgraded: false,
|
|
522
|
+
blocked: true,
|
|
523
|
+
enforcement_reason: enforcementReason
|
|
524
|
+
});
|
|
525
|
+
if (config.mode === "alert_only") {
|
|
526
|
+
} else {
|
|
527
|
+
throw Object.assign(new Error(blockError.message), {
|
|
528
|
+
caplyr: blockError
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
if (config.mode === "cost_protect") {
|
|
533
|
+
if (heartbeat.isMonthlyBudgetExceeded() || heartbeat.isDailyBudgetExceeded()) {
|
|
534
|
+
blocked = true;
|
|
535
|
+
enforcementReason = heartbeat.isDailyBudgetExceeded() ? "daily_budget_exceeded" : "monthly_budget_exceeded";
|
|
536
|
+
const blockError = {
|
|
537
|
+
code: "BUDGET_EXCEEDED",
|
|
538
|
+
message: `AI budget exceeded. ${enforcementReason.replace(/_/g, " ")}.`,
|
|
539
|
+
budget_used: heartbeat.budgetStatus.monthly_used,
|
|
540
|
+
budget_limit: heartbeat.budgetStatus.monthly_limit ?? 0,
|
|
541
|
+
retry_after: getNextResetTime2(enforcementReason),
|
|
542
|
+
dashboard_url: dashboardUrl
|
|
543
|
+
};
|
|
544
|
+
shipper.push({
|
|
545
|
+
id: generateId2(),
|
|
546
|
+
timestamp: startTime,
|
|
547
|
+
provider: "openai",
|
|
548
|
+
model,
|
|
549
|
+
input_tokens: 0,
|
|
550
|
+
output_tokens: 0,
|
|
551
|
+
cost: 0,
|
|
552
|
+
latency_ms: Date.now() - startTime,
|
|
553
|
+
endpoint_tag: config.endpoint_tag,
|
|
554
|
+
downgraded: false,
|
|
555
|
+
blocked: true,
|
|
556
|
+
enforcement_reason: enforcementReason
|
|
557
|
+
});
|
|
558
|
+
throw Object.assign(new Error(blockError.message), {
|
|
559
|
+
caplyr: blockError
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
if (heartbeat.isDowngradeThresholdReached(downgradeThreshold)) {
|
|
563
|
+
const fallback = config.fallback ?? getDefaultFallback(model);
|
|
564
|
+
if (fallback && fallback !== model) {
|
|
565
|
+
originalModel = model;
|
|
566
|
+
model = fallback;
|
|
567
|
+
downgraded = true;
|
|
568
|
+
enforcementReason = "auto_downgrade_threshold";
|
|
569
|
+
config.onEnforcement?.({
|
|
570
|
+
type: "downgrade",
|
|
571
|
+
timestamp: Date.now(),
|
|
572
|
+
reason: `Budget at ${Math.round(downgradeThreshold * 100)}% \u2014 downgraded ${originalModel} \u2192 ${model}`,
|
|
573
|
+
original_model: originalModel,
|
|
574
|
+
fallback_model: model,
|
|
575
|
+
budget_used: heartbeat.budgetStatus.monthly_used,
|
|
576
|
+
budget_limit: heartbeat.budgetStatus.monthly_limit ?? 0,
|
|
577
|
+
estimated_savings: 0
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
const requestParams = downgraded ? { ...params, model } : params;
|
|
471
583
|
try {
|
|
472
|
-
const response = await target.create.call(
|
|
584
|
+
const response = await target.create.call(
|
|
585
|
+
target,
|
|
586
|
+
requestParams,
|
|
587
|
+
options
|
|
588
|
+
);
|
|
589
|
+
const latency = Date.now() - startTime;
|
|
473
590
|
const usage = response?.usage;
|
|
474
591
|
const inputTokens = usage?.prompt_tokens ?? 0;
|
|
475
592
|
const outputTokens = usage?.completion_tokens ?? 0;
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
startTime,
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
593
|
+
const cost = calculateCost(model, inputTokens, outputTokens);
|
|
594
|
+
heartbeat.trackSpend(cost);
|
|
595
|
+
shipper.push({
|
|
596
|
+
id: generateId2(),
|
|
597
|
+
timestamp: startTime,
|
|
598
|
+
provider: "openai",
|
|
599
|
+
model,
|
|
600
|
+
input_tokens: inputTokens,
|
|
601
|
+
output_tokens: outputTokens,
|
|
602
|
+
cost,
|
|
603
|
+
latency_ms: latency,
|
|
604
|
+
endpoint_tag: config.endpoint_tag,
|
|
605
|
+
downgraded,
|
|
606
|
+
original_model: originalModel,
|
|
607
|
+
blocked: false,
|
|
608
|
+
enforcement_reason: enforcementReason
|
|
609
|
+
});
|
|
487
610
|
return response;
|
|
488
611
|
} catch (err) {
|
|
489
612
|
if (err?.caplyr) throw err;
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
613
|
+
shipper.push({
|
|
614
|
+
id: generateId2(),
|
|
615
|
+
timestamp: startTime,
|
|
616
|
+
provider: "openai",
|
|
617
|
+
model,
|
|
618
|
+
input_tokens: 0,
|
|
619
|
+
output_tokens: 0,
|
|
620
|
+
cost: 0,
|
|
621
|
+
latency_ms: Date.now() - startTime,
|
|
622
|
+
endpoint_tag: config.endpoint_tag,
|
|
623
|
+
downgraded,
|
|
624
|
+
original_model: originalModel,
|
|
625
|
+
blocked: false,
|
|
626
|
+
enforcement_reason: "provider_error"
|
|
627
|
+
});
|
|
498
628
|
throw err;
|
|
499
629
|
}
|
|
500
630
|
};
|
|
@@ -504,17 +634,32 @@ function wrapOpenAI(client, config, shipper, heartbeat) {
|
|
|
504
634
|
});
|
|
505
635
|
const chatProxy = new Proxy(client.chat, {
|
|
506
636
|
get(target, prop, receiver) {
|
|
507
|
-
if (prop === "completions")
|
|
637
|
+
if (prop === "completions") {
|
|
638
|
+
return completionsProxy;
|
|
639
|
+
}
|
|
508
640
|
return Reflect.get(target, prop, receiver);
|
|
509
641
|
}
|
|
510
642
|
});
|
|
511
643
|
return new Proxy(client, {
|
|
512
644
|
get(target, prop, receiver) {
|
|
513
|
-
if (prop === "chat")
|
|
645
|
+
if (prop === "chat") {
|
|
646
|
+
return chatProxy;
|
|
647
|
+
}
|
|
514
648
|
return Reflect.get(target, prop, receiver);
|
|
515
649
|
}
|
|
516
650
|
});
|
|
517
651
|
}
|
|
652
|
+
function getNextResetTime2(reason) {
|
|
653
|
+
const now = /* @__PURE__ */ new Date();
|
|
654
|
+
if (reason.includes("daily")) {
|
|
655
|
+
const tomorrow = new Date(now);
|
|
656
|
+
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
657
|
+
tomorrow.setHours(0, 0, 0, 0);
|
|
658
|
+
return tomorrow.toISOString();
|
|
659
|
+
}
|
|
660
|
+
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
|
661
|
+
return nextMonth.toISOString();
|
|
662
|
+
}
|
|
518
663
|
|
|
519
664
|
// src/protect.ts
|
|
520
665
|
function detectProvider(client) {
|
|
@@ -529,28 +674,32 @@ function detectProvider(client) {
|
|
|
529
674
|
var instances = /* @__PURE__ */ new Map();
|
|
530
675
|
function protect(client, config) {
|
|
531
676
|
if (!config.apiKey) {
|
|
532
|
-
throw new Error(
|
|
677
|
+
throw new Error(
|
|
678
|
+
"Caplyr: apiKey is required. Get yours at https://app.caplyr.com"
|
|
679
|
+
);
|
|
533
680
|
}
|
|
534
|
-
const
|
|
681
|
+
const userWantsProtect = config.budget && !config.mode;
|
|
535
682
|
const resolvedConfig = {
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
683
|
+
mode: userWantsProtect ? "cost_protect" : "alert_only",
|
|
684
|
+
downgradeThreshold: 0.8,
|
|
685
|
+
batchSize: 10,
|
|
686
|
+
flushInterval: 3e4,
|
|
687
|
+
heartbeatInterval: 6e4,
|
|
688
|
+
...config
|
|
540
689
|
};
|
|
541
690
|
let shared = instances.get(resolvedConfig.apiKey);
|
|
542
691
|
if (!shared) {
|
|
543
692
|
const shipper2 = new LogShipper(resolvedConfig);
|
|
544
693
|
const heartbeat2 = new Heartbeat(resolvedConfig);
|
|
545
694
|
heartbeat2.start();
|
|
546
|
-
shared = { shipper: shipper2, heartbeat: heartbeat2
|
|
695
|
+
shared = { shipper: shipper2, heartbeat: heartbeat2 };
|
|
547
696
|
instances.set(resolvedConfig.apiKey, shared);
|
|
548
697
|
}
|
|
549
698
|
const { shipper, heartbeat } = shared;
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
}
|
|
699
|
+
if (resolvedConfig.budget) {
|
|
700
|
+
const budgetConfig = typeof resolvedConfig.budget === "number" ? { monthly: resolvedConfig.budget } : resolvedConfig.budget;
|
|
701
|
+
heartbeat.applyLocalLimits(budgetConfig);
|
|
702
|
+
}
|
|
554
703
|
const provider = detectProvider(client);
|
|
555
704
|
switch (provider) {
|
|
556
705
|
case "anthropic":
|
|
@@ -559,26 +708,30 @@ function protect(client, config) {
|
|
|
559
708
|
return wrapOpenAI(client, resolvedConfig, shipper, heartbeat);
|
|
560
709
|
default:
|
|
561
710
|
throw new Error(
|
|
562
|
-
|
|
711
|
+
`Caplyr: Unrecognized AI client. Supported providers: Anthropic, OpenAI. Make sure you're passing a client instance (e.g., new Anthropic() or new OpenAI()).`
|
|
563
712
|
);
|
|
564
713
|
}
|
|
565
714
|
}
|
|
566
715
|
function getStatus(apiKey) {
|
|
567
|
-
|
|
716
|
+
const shared = instances.get(apiKey);
|
|
717
|
+
return shared?.heartbeat.status ?? "OFF";
|
|
568
718
|
}
|
|
569
719
|
function getState(apiKey) {
|
|
570
720
|
const shared = instances.get(apiKey);
|
|
571
721
|
if (!shared) return null;
|
|
572
|
-
const { heartbeat
|
|
722
|
+
const { heartbeat } = shared;
|
|
573
723
|
return {
|
|
574
724
|
status: heartbeat.status,
|
|
575
|
-
mode,
|
|
725
|
+
mode: "alert_only",
|
|
726
|
+
// Will be stored properly in next iteration
|
|
576
727
|
budget_daily_used: heartbeat.budgetStatus.daily_used,
|
|
577
728
|
budget_monthly_used: heartbeat.budgetStatus.monthly_used,
|
|
578
729
|
kill_switch_active: heartbeat.budgetStatus.kill_switch_active,
|
|
579
|
-
last_heartbeat:
|
|
580
|
-
request_count:
|
|
581
|
-
|
|
730
|
+
last_heartbeat: Date.now(),
|
|
731
|
+
request_count: 0,
|
|
732
|
+
// Will be tracked in next iteration
|
|
733
|
+
total_cost: heartbeat.budgetStatus.monthly_used,
|
|
734
|
+
total_savings: 0
|
|
582
735
|
};
|
|
583
736
|
}
|
|
584
737
|
async function shutdown(apiKey) {
|
|
@@ -586,16 +739,14 @@ async function shutdown(apiKey) {
|
|
|
586
739
|
const shared = instances.get(apiKey);
|
|
587
740
|
if (shared) {
|
|
588
741
|
shared.heartbeat.destroy();
|
|
589
|
-
|
|
742
|
+
shared.shipper.destroy();
|
|
590
743
|
instances.delete(apiKey);
|
|
591
744
|
}
|
|
592
745
|
} else {
|
|
593
|
-
const
|
|
594
|
-
for (const [, shared] of instances) {
|
|
746
|
+
for (const [key, shared] of instances) {
|
|
595
747
|
shared.heartbeat.destroy();
|
|
596
|
-
|
|
748
|
+
shared.shipper.destroy();
|
|
597
749
|
}
|
|
598
|
-
await Promise.all(promises);
|
|
599
750
|
instances.clear();
|
|
600
751
|
}
|
|
601
752
|
}
|