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