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