caplyr 0.1.9 → 0.2.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/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,25 @@ 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
- this.endpoint = config.endpoint ?? "https://caplyr.com";
127
+ this.endpoint = config.endpoint ?? "https://api.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
+ * Start the heartbeat loop.
135
+ * Immediately sends first heartbeat, then repeats on interval.
136
+ */
138
137
  start() {
139
138
  this.beat();
140
139
  this.timer = setInterval(() => this.beat(), this.interval);
141
- if (this.timer.unref) this.timer.unref();
142
140
  }
141
+ /**
142
+ * Send a single heartbeat and update local state.
143
+ */
143
144
  async beat() {
144
145
  try {
145
146
  const res = await fetch(`${this.endpoint}/api/heartbeat`, {
@@ -148,17 +149,15 @@ var Heartbeat = class {
148
149
  "Content-Type": "application/json",
149
150
  "Authorization": `Bearer ${this.apiKey}`
150
151
  },
151
- body: JSON.stringify({
152
- timestamp: Date.now(),
153
- ...this.budget && { budget: this.budget }
154
- }),
152
+ body: JSON.stringify({ timestamp: Date.now() }),
155
153
  signal: AbortSignal.timeout(5e3)
156
154
  });
157
- if (!res.ok) throw new Error(`Heartbeat failed: ${res.status}`);
155
+ if (!res.ok) {
156
+ throw new Error(`Heartbeat failed: ${res.status}`);
157
+ }
158
158
  const data = await res.json();
159
159
  this.budgetStatus = data;
160
160
  this.consecutiveFailures = 0;
161
- this.lastHeartbeatAt = Date.now();
162
161
  const newStatus = data.kill_switch_active ? "OFF" : data.status;
163
162
  if (newStatus !== this.status) {
164
163
  this.status = newStatus;
@@ -173,18 +172,32 @@ var Heartbeat = class {
173
172
  }
174
173
  }
175
174
  }
175
+ /**
176
+ * Update local budget tracking (called after each request).
177
+ * This provides real-time budget awareness between heartbeats.
178
+ */
176
179
  trackSpend(cost) {
177
180
  this.budgetStatus.daily_used += cost;
178
181
  this.budgetStatus.monthly_used += cost;
179
182
  }
183
+ /**
184
+ * Check if the monthly budget is exceeded.
185
+ */
180
186
  isMonthlyBudgetExceeded() {
181
187
  if (this.budgetStatus.monthly_limit === null) return false;
182
188
  return this.budgetStatus.monthly_used >= this.budgetStatus.monthly_limit;
183
189
  }
190
+ /**
191
+ * Check if the daily budget is exceeded.
192
+ */
184
193
  isDailyBudgetExceeded() {
185
194
  if (this.budgetStatus.daily_limit === null) return false;
186
195
  return this.budgetStatus.daily_used >= this.budgetStatus.daily_limit;
187
196
  }
197
+ /**
198
+ * Check if the downgrade threshold is reached.
199
+ * Returns true if usage exceeds the given threshold (0-1) of any budget.
200
+ */
188
201
  isDowngradeThresholdReached(threshold) {
189
202
  if (this.budgetStatus.monthly_limit !== null && this.budgetStatus.monthly_used >= this.budgetStatus.monthly_limit * threshold) {
190
203
  return true;
@@ -194,9 +207,15 @@ var Heartbeat = class {
194
207
  }
195
208
  return false;
196
209
  }
210
+ /**
211
+ * Check if the kill switch is active.
212
+ */
197
213
  isKillSwitchActive() {
198
214
  return this.budgetStatus.kill_switch_active;
199
215
  }
216
+ /**
217
+ * Stop the heartbeat loop.
218
+ */
200
219
  destroy() {
201
220
  if (this.timer) {
202
221
  clearInterval(this.timer);
@@ -211,6 +230,7 @@ var MODEL_PRICING = {
211
230
  "claude-opus-4-20250514": { input: 15, output: 75 },
212
231
  "claude-sonnet-4-20250514": { input: 3, output: 15 },
213
232
  "claude-haiku-4-5-20251001": { input: 0.8, output: 4 },
233
+ // Aliases
214
234
  "claude-opus-4": { input: 15, output: 75 },
215
235
  "claude-sonnet-4": { input: 3, output: 15 },
216
236
  "claude-3-5-sonnet-20241022": { input: 3, output: 15 },
@@ -228,11 +248,13 @@ var MODEL_PRICING = {
228
248
  "o3-mini": { input: 1.1, output: 4.4 }
229
249
  };
230
250
  var DEFAULT_FALLBACKS = {
251
+ // Anthropic downgrades
231
252
  "claude-opus-4-20250514": "claude-sonnet-4-20250514",
232
253
  "claude-opus-4": "claude-sonnet-4",
233
254
  "claude-sonnet-4-20250514": "claude-haiku-4-5-20251001",
234
255
  "claude-sonnet-4": "claude-haiku-4-5-20251001",
235
256
  "claude-3-5-sonnet-20241022": "claude-haiku-4-5-20251001",
257
+ // OpenAI downgrades
236
258
  "gpt-4o": "gpt-4o-mini",
237
259
  "gpt-4o-2024-11-20": "gpt-4o-mini",
238
260
  "gpt-4-turbo": "gpt-4o-mini",
@@ -252,7 +274,9 @@ function getDefaultFallback(model) {
252
274
  }
253
275
  function registerModel(model, pricing, fallback) {
254
276
  MODEL_PRICING[model] = pricing;
255
- if (fallback) DEFAULT_FALLBACKS[model] = fallback;
277
+ if (fallback) {
278
+ DEFAULT_FALLBACKS[model] = fallback;
279
+ }
256
280
  }
257
281
  function isKnownModel(model) {
258
282
  return model in MODEL_PRICING;
@@ -261,182 +285,160 @@ function getModelPricing(model) {
261
285
  return MODEL_PRICING[model] ?? null;
262
286
  }
263
287
 
264
- // src/utils.ts
288
+ // src/interceptors/anthropic.ts
265
289
  var idCounter = 0;
266
290
  function generateId() {
267
- return `caplyr_${Date.now()}_${process.pid ?? 0}_${++idCounter}`;
291
+ return `caplyr_${Date.now()}_${++idCounter}`;
268
292
  }
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;
363
- }
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
293
  function wrapAnthropic(client, config, shipper, heartbeat) {
294
+ const downgradeThreshold = config.downgradeThreshold ?? 0.8;
295
+ const dashboardUrl = `${config.endpoint ?? "https://app.caplyr.com"}/dashboard`;
400
296
  const messagesProxy = new Proxy(client.messages, {
401
297
  get(target, prop, receiver) {
402
298
  if (prop === "create") {
403
299
  return async function caplyrInterceptedCreate(params, options) {
404
300
  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;
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;
414
389
  try {
415
- const response = await target.create.call(target, requestParams, options);
390
+ const response = await target.create.call(
391
+ target,
392
+ requestParams,
393
+ options
394
+ );
395
+ const latency = Date.now() - startTime;
416
396
  const inputTokens = response?.usage?.input_tokens ?? 0;
417
397
  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
- );
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
+ });
429
424
  return response;
430
425
  } catch (err) {
431
426
  if (err?.caplyr) throw err;
432
- logProviderError(
433
- shipper,
434
- "anthropic",
435
- enforcement.model,
436
- startTime,
437
- enforcement,
438
- config
439
- );
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
+ });
440
442
  throw err;
441
443
  }
442
444
  };
@@ -446,55 +448,170 @@ function wrapAnthropic(client, config, shipper, heartbeat) {
446
448
  });
447
449
  return new Proxy(client, {
448
450
  get(target, prop, receiver) {
449
- if (prop === "messages") return messagesProxy;
451
+ if (prop === "messages") {
452
+ return messagesProxy;
453
+ }
450
454
  return Reflect.get(target, prop, receiver);
451
455
  }
452
456
  });
453
457
  }
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
+ }
454
469
 
455
470
  // src/interceptors/openai.ts
471
+ var idCounter2 = 0;
472
+ function generateId2() {
473
+ return `caplyr_${Date.now()}_${++idCounter2}`;
474
+ }
456
475
  function wrapOpenAI(client, config, shipper, heartbeat) {
476
+ const downgradeThreshold = config.downgradeThreshold ?? 0.8;
477
+ const dashboardUrl = `${config.endpoint ?? "https://app.caplyr.com"}/dashboard`;
457
478
  const completionsProxy = new Proxy(client.chat.completions, {
458
479
  get(target, prop, receiver) {
459
480
  if (prop === "create") {
460
481
  return async function caplyrInterceptedCreate(params, options) {
461
482
  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;
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;
471
570
  try {
472
- const response = await target.create.call(target, requestParams, options);
571
+ const response = await target.create.call(
572
+ target,
573
+ requestParams,
574
+ options
575
+ );
576
+ const latency = Date.now() - startTime;
473
577
  const usage = response?.usage;
474
578
  const inputTokens = usage?.prompt_tokens ?? 0;
475
579
  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
- );
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
+ });
487
597
  return response;
488
598
  } catch (err) {
489
599
  if (err?.caplyr) throw err;
490
- logProviderError(
491
- shipper,
492
- "openai",
493
- enforcement.model,
494
- startTime,
495
- enforcement,
496
- config
497
- );
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
+ });
498
615
  throw err;
499
616
  }
500
617
  };
@@ -504,17 +621,32 @@ function wrapOpenAI(client, config, shipper, heartbeat) {
504
621
  });
505
622
  const chatProxy = new Proxy(client.chat, {
506
623
  get(target, prop, receiver) {
507
- if (prop === "completions") return completionsProxy;
624
+ if (prop === "completions") {
625
+ return completionsProxy;
626
+ }
508
627
  return Reflect.get(target, prop, receiver);
509
628
  }
510
629
  });
511
630
  return new Proxy(client, {
512
631
  get(target, prop, receiver) {
513
- if (prop === "chat") return chatProxy;
632
+ if (prop === "chat") {
633
+ return chatProxy;
634
+ }
514
635
  return Reflect.get(target, prop, receiver);
515
636
  }
516
637
  });
517
638
  }
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
+ }
518
650
 
519
651
  // src/protect.ts
520
652
  function detectProvider(client) {
@@ -529,28 +661,27 @@ function detectProvider(client) {
529
661
  var instances = /* @__PURE__ */ new Map();
530
662
  function protect(client, config) {
531
663
  if (!config.apiKey) {
532
- throw new Error("Caplyr: apiKey is required. Get yours at https://app.caplyr.com");
664
+ throw new Error(
665
+ "Caplyr: apiKey is required. Get yours at https://app.caplyr.com"
666
+ );
533
667
  }
534
- const mode = config.mode ?? (config.budget ? "cost_protect" : "alert_only");
535
668
  const resolvedConfig = {
536
- ...config,
537
- mode,
538
- downgradeThreshold: config.downgradeThreshold ?? 0.8,
539
- dashboardUrl: config.dashboardUrl ?? "https://app.caplyr.com"
669
+ mode: config.budget && !config.mode ? "cost_protect" : "alert_only",
670
+ downgradeThreshold: 0.8,
671
+ batchSize: 10,
672
+ flushInterval: 3e4,
673
+ heartbeatInterval: 6e4,
674
+ ...config
540
675
  };
541
676
  let shared = instances.get(resolvedConfig.apiKey);
542
677
  if (!shared) {
543
678
  const shipper2 = new LogShipper(resolvedConfig);
544
679
  const heartbeat2 = new Heartbeat(resolvedConfig);
545
680
  heartbeat2.start();
546
- shared = { shipper: shipper2, heartbeat: heartbeat2, mode, requestCount: 0 };
681
+ shared = { shipper: shipper2, heartbeat: heartbeat2 };
547
682
  instances.set(resolvedConfig.apiKey, shared);
548
683
  }
549
684
  const { shipper, heartbeat } = shared;
550
- const sharedRef = shared;
551
- resolvedConfig._onRequest = () => {
552
- sharedRef.requestCount++;
553
- };
554
685
  const provider = detectProvider(client);
555
686
  switch (provider) {
556
687
  case "anthropic":
@@ -559,26 +690,30 @@ function protect(client, config) {
559
690
  return wrapOpenAI(client, resolvedConfig, shipper, heartbeat);
560
691
  default:
561
692
  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())."
693
+ `Caplyr: Unrecognized AI client. Supported providers: Anthropic, OpenAI. Make sure you're passing a client instance (e.g., new Anthropic() or new OpenAI()).`
563
694
  );
564
695
  }
565
696
  }
566
697
  function getStatus(apiKey) {
567
- return instances.get(apiKey)?.heartbeat.status ?? "OFF";
698
+ const shared = instances.get(apiKey);
699
+ return shared?.heartbeat.status ?? "OFF";
568
700
  }
569
701
  function getState(apiKey) {
570
702
  const shared = instances.get(apiKey);
571
703
  if (!shared) return null;
572
- const { heartbeat, mode, requestCount } = shared;
704
+ const { heartbeat } = shared;
573
705
  return {
574
706
  status: heartbeat.status,
575
- mode,
707
+ mode: "alert_only",
708
+ // Will be stored properly in next iteration
576
709
  budget_daily_used: heartbeat.budgetStatus.daily_used,
577
710
  budget_monthly_used: heartbeat.budgetStatus.monthly_used,
578
711
  kill_switch_active: heartbeat.budgetStatus.kill_switch_active,
579
- last_heartbeat: heartbeat.lastHeartbeatAt,
580
- request_count: requestCount,
581
- total_cost: heartbeat.budgetStatus.monthly_used
712
+ last_heartbeat: Date.now(),
713
+ request_count: 0,
714
+ // Will be tracked in next iteration
715
+ total_cost: heartbeat.budgetStatus.monthly_used,
716
+ total_savings: 0
582
717
  };
583
718
  }
584
719
  async function shutdown(apiKey) {
@@ -586,16 +721,14 @@ async function shutdown(apiKey) {
586
721
  const shared = instances.get(apiKey);
587
722
  if (shared) {
588
723
  shared.heartbeat.destroy();
589
- await shared.shipper.destroy();
724
+ shared.shipper.destroy();
590
725
  instances.delete(apiKey);
591
726
  }
592
727
  } else {
593
- const promises = [];
594
- for (const [, shared] of instances) {
728
+ for (const [key, shared] of instances) {
595
729
  shared.heartbeat.destroy();
596
- promises.push(shared.shipper.destroy());
730
+ shared.shipper.destroy();
597
731
  }
598
- await Promise.all(promises);
599
732
  instances.clear();
600
733
  }
601
734
  }