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