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/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 (this.timer.unref) this.timer.unref();
61
- activeShippers.add(this);
62
- registerProcessHandlers();
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.requeueFailed(batch);
85
- this.onError?.(new Error(`Ingest failed: ${res.status} ${res.statusText}`));
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
- * Re-queue failed logs, dropping oldest if buffer would exceed max size.
91
+ * Stop the periodic flush timer.
92
+ * Call this when tearing down the SDK.
94
93
  */
95
- requeueFailed(batch) {
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
- await this.flush();
107
- activeShippers.delete(this);
99
+ this.flush();
108
100
  }
109
- /** @deprecated Use destroy() instead */
110
101
  async shutdown() {
111
- return this.destroy();
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) throw new Error(`Heartbeat failed: ${res.status}`);
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) DEFAULT_FALLBACKS[model] = 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/utils.ts
301
+ // src/interceptors/anthropic.ts
265
302
  var idCounter = 0;
266
303
  function generateId() {
267
- return `caplyr_${Date.now()}_${process.pid ?? 0}_${++idCounter}`;
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
- const enforcement = enforcePreCall(
406
- params.model,
407
- config,
408
- heartbeat,
409
- shipper,
410
- "anthropic",
411
- startTime
412
- );
413
- const requestParams = enforcement.downgraded ? { ...params, model: enforcement.model } : params;
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(target, requestParams, options);
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
- logSuccess(
419
- shipper,
420
- "anthropic",
421
- enforcement.model,
422
- startTime,
423
- inputTokens,
424
- outputTokens,
425
- enforcement,
426
- heartbeat,
427
- config
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
- logProviderError(
433
- shipper,
434
- "anthropic",
435
- enforcement.model,
436
- startTime,
437
- enforcement,
438
- config
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") return messagesProxy;
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
- const enforcement = enforcePreCall(
463
- params.model,
464
- config,
465
- heartbeat,
466
- shipper,
467
- "openai",
468
- startTime
469
- );
470
- const requestParams = enforcement.downgraded ? { ...params, model: enforcement.model } : params;
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(target, requestParams, options);
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
- logSuccess(
477
- shipper,
478
- "openai",
479
- enforcement.model,
480
- startTime,
481
- inputTokens,
482
- outputTokens,
483
- enforcement,
484
- heartbeat,
485
- config
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
- logProviderError(
491
- shipper,
492
- "openai",
493
- enforcement.model,
494
- startTime,
495
- enforcement,
496
- config
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") return completionsProxy;
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") return chatProxy;
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("Caplyr: apiKey is required. Get yours at https://app.caplyr.com");
677
+ throw new Error(
678
+ "Caplyr: apiKey is required. Get yours at https://app.caplyr.com"
679
+ );
533
680
  }
534
- const mode = config.mode ?? (config.budget ? "cost_protect" : "alert_only");
681
+ const userWantsProtect = config.budget && !config.mode;
535
682
  const resolvedConfig = {
536
- ...config,
537
- mode,
538
- downgradeThreshold: config.downgradeThreshold ?? 0.8,
539
- dashboardUrl: config.dashboardUrl ?? "https://app.caplyr.com"
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, mode, requestCount: 0 };
695
+ shared = { shipper: shipper2, heartbeat: heartbeat2 };
547
696
  instances.set(resolvedConfig.apiKey, shared);
548
697
  }
549
698
  const { shipper, heartbeat } = shared;
550
- const sharedRef = shared;
551
- resolvedConfig._onRequest = () => {
552
- sharedRef.requestCount++;
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
- "Caplyr: Unrecognized AI client. Supported providers: Anthropic, OpenAI. Make sure you're passing a client instance (e.g., new Anthropic() or new OpenAI())."
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
- return instances.get(apiKey)?.heartbeat.status ?? "OFF";
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, mode, requestCount } = shared;
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: heartbeat.lastHeartbeatAt,
580
- request_count: requestCount,
581
- total_cost: heartbeat.budgetStatus.monthly_used
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
- await shared.shipper.destroy();
742
+ shared.shipper.destroy();
590
743
  instances.delete(apiKey);
591
744
  }
592
745
  } else {
593
- const promises = [];
594
- for (const [, shared] of instances) {
746
+ for (const [key, shared] of instances) {
595
747
  shared.heartbeat.destroy();
596
- promises.push(shared.shipper.destroy());
748
+ shared.shipper.destroy();
597
749
  }
598
- await Promise.all(promises);
599
750
  instances.clear();
600
751
  }
601
752
  }