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.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.buffer.unshift(...batch);
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
- * Stop the periodic flush timer.
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/interceptors/anthropic.ts
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 wrapAnthropic(client, config, shipper, heartbeat) {
261
- const downgradeThreshold = config.downgradeThreshold ?? 0.8;
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
- let model = params.model;
269
- let downgraded = false;
270
- let originalModel;
271
- let blocked = false;
272
- let enforcementReason;
273
- if (heartbeat.isKillSwitchActive()) {
274
- blocked = true;
275
- enforcementReason = "kill_switch_active";
276
- const blockError = {
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
- const cost = calculateCost(model, inputTokens, outputTokens);
366
- heartbeat.trackSpend(cost);
367
- let estimatedSavings = 0;
368
- if (downgraded && originalModel) {
369
- const originalCost = calculateCost(
370
- originalModel,
371
- inputTokens,
372
- outputTokens
373
- );
374
- estimatedSavings = originalCost - cost;
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
- shipper.push({
395
- id: generateId(),
396
- timestamp: startTime,
397
- provider: "anthropic",
398
- model,
399
- input_tokens: 0,
400
- output_tokens: 0,
401
- cost: 0,
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
- let model = params.model;
451
- let downgraded = false;
452
- let originalModel;
453
- let blocked = false;
454
- let enforcementReason;
455
- if (heartbeat.isKillSwitchActive()) {
456
- blocked = true;
457
- enforcementReason = "kill_switch_active";
458
- const blockError = {
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
- const cost = calculateCost(model, inputTokens, outputTokens);
548
- heartbeat.trackSpend(cost);
549
- shipper.push({
550
- id: generateId2(),
551
- timestamp: startTime,
552
- provider: "openai",
553
- model,
554
- input_tokens: inputTokens,
555
- output_tokens: outputTokens,
556
- cost,
557
- latency_ms: latency,
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
- shipper.push({
568
- id: generateId2(),
569
- timestamp: startTime,
570
- provider: "openai",
571
- model,
572
- input_tokens: 0,
573
- output_tokens: 0,
574
- cost: 0,
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
- mode: "alert_only",
637
- downgradeThreshold: 0.8,
638
- batchSize: 10,
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
- `Caplyr: Unrecognized AI client. Supported providers: Anthropic, OpenAI. Make sure you're passing a client instance (e.g., new Anthropic() or new OpenAI()).`
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
- const shared = instances.get(apiKey);
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: "alert_only",
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: 0,
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 [key, shared] of instances) {
545
+ for (const [, shared] of instances) {
699
546
  shared.heartbeat.destroy();
700
547
  shared.shipper.destroy();
701
548
  }