budget-agent 0.4.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 ADDED
@@ -0,0 +1,557 @@
1
+ import { CheckpointManager } from './checkpoint.js';
2
+ import { UsageTracker } from './tracker.js';
3
+ import { getModelPricing, calculateCost, invalidatePricingCache } from './pricing.js';
4
+ import { checkLimits, BudgetError, RateLimitError, UpstreamError } from './budget.js';
5
+ import { compressMessages as compressMessageHistory, estimateMessagesTokens, } from './compressor.js';
6
+ import { estimateStepCost } from './estimator.js';
7
+ import { CircuitBreaker } from './circuit-breaker.js';
8
+ import { resolveModel, shouldLogDowngrade } from './router.js';
9
+ import { AgentEventEmitter, WarningChecker } from './events.js';
10
+ const DEFAULT_BASE_URL = 'https://openrouter.ai/api/v1';
11
+ const DEFAULT_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
12
+ // ─── AgentBudget ─────────────────────────────────────────────────────────────
13
+ export class AgentBudget {
14
+ apiKey;
15
+ limits;
16
+ onExceeded;
17
+ cacheTTL;
18
+ siteUrl;
19
+ appTitle;
20
+ autoCompress;
21
+ adaptiveRouting;
22
+ currentModelIndex = 0;
23
+ tracker;
24
+ circuitBreaker;
25
+ checkpointManager;
26
+ emitter;
27
+ warningChecker = new WarningChecker();
28
+ warningThreshold;
29
+ telemetry;
30
+ tracer = null;
31
+ executor;
32
+ baseUrl;
33
+ defaultHeaders;
34
+ constructor(options) {
35
+ const l = options.limits;
36
+ if (l.maxCostUSD !== undefined && l.maxCostUSD < 0)
37
+ throw new Error('[agent-budget] maxCostUSD must be >= 0');
38
+ if (l.maxSteps !== undefined && l.maxSteps < 0)
39
+ throw new Error('[agent-budget] maxSteps must be >= 0');
40
+ if (l.maxTotalTokens !== undefined && l.maxTotalTokens < 0)
41
+ throw new Error('[agent-budget] maxTotalTokens must be >= 0');
42
+ if (l.maxInputTokens !== undefined && l.maxInputTokens < 0)
43
+ throw new Error('[agent-budget] maxInputTokens must be >= 0');
44
+ if (l.maxOutputTokens !== undefined && l.maxOutputTokens < 0)
45
+ throw new Error('[agent-budget] maxOutputTokens must be >= 0');
46
+ if (l.maxWallTimeMs !== undefined && l.maxWallTimeMs < 0)
47
+ throw new Error('[agent-budget] maxWallTimeMs must be >= 0');
48
+ this.apiKey = options.apiKey;
49
+ this.limits = l;
50
+ this.onExceeded = options.onExceeded ?? 'abort';
51
+ this.cacheTTL = options.pricingCacheTTLMs ?? DEFAULT_CACHE_TTL;
52
+ this.siteUrl = options.siteUrl;
53
+ this.appTitle = options.appTitle;
54
+ this.autoCompress = options.autoCompress;
55
+ this.adaptiveRouting = options.adaptiveRouting;
56
+ this.tracker = new UsageTracker();
57
+ this.circuitBreaker = options.circuitBreaker
58
+ ? new CircuitBreaker(options.circuitBreaker)
59
+ : null;
60
+ this.checkpointManager = options.checkpoint?.enabled
61
+ ? new CheckpointManager({ path: options.checkpoint.path })
62
+ : null;
63
+ this.emitter = new AgentEventEmitter(options.onEvent);
64
+ this.warningThreshold = options.warningThreshold ?? 0.75;
65
+ this.telemetry = options.telemetry;
66
+ this.executor = options.executor;
67
+ this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
68
+ this.defaultHeaders = options.defaultHeaders ?? {};
69
+ }
70
+ /**
71
+ * Execute one agent step through OpenRouter.
72
+ * Checks budget limits before AND after the API call.
73
+ * Throws BudgetError if any limit is exceeded.
74
+ */
75
+ async step(request) {
76
+ const stepIndex = this.tracker.stepCount();
77
+ const stepStart = Date.now();
78
+ this._initTracer();
79
+ const stepSpan = this._startSpan('agent-budget.step');
80
+ // ── Adaptive model routing: resolve model from fallback chain ────────────
81
+ // Runs BEFORE pre-flight checks so that fallbackChainExhausted fires
82
+ // instead of a generic cost error when on the last tier.
83
+ if (this.adaptiveRouting) {
84
+ const { fallbackChain, thresholds } = this.adaptiveRouting;
85
+ const usage = this.tracker.snapshot();
86
+ const decision = resolveModel(fallbackChain, thresholds, usage, this.limits.maxCostUSD);
87
+ const prevIndex = this.currentModelIndex;
88
+ this.currentModelIndex = decision.index;
89
+ // Override the request model with the router's decision
90
+ request.model = decision.model;
91
+ // Check if chain is exhausted and budget is critically over
92
+ if (decision.index >= fallbackChain.length - 1) {
93
+ const pct = this.limits.maxCostUSD ? usage.totalCostUSD / this.limits.maxCostUSD : 1;
94
+ if (pct >= 1) {
95
+ const exceeded = {
96
+ reason: 'fallbackChainExhausted',
97
+ limit: this.limits.maxCostUSD ?? 0,
98
+ actual: usage.totalCostUSD,
99
+ usage,
100
+ };
101
+ this.emitter.emit({ type: 'budget:exceeded', exceeded });
102
+ if (typeof this.onExceeded === 'function') {
103
+ this.onExceeded(usage);
104
+ }
105
+ throw new BudgetError(exceeded);
106
+ }
107
+ }
108
+ // Log downgrade if moving to a cheaper tier
109
+ if (shouldLogDowngrade(prevIndex, decision.index)) {
110
+ const pct = this.limits.maxCostUSD
111
+ ? usage.totalCostUSD / this.limits.maxCostUSD
112
+ : 0;
113
+ console.log(`[agent-budget] Downgrading model: ${fallbackChain[prevIndex]} → ${decision.model} (budget ${(pct * 100).toFixed(1)}% consumed)`);
114
+ this.emitter.emit({
115
+ type: 'model:downgraded',
116
+ from: fallbackChain[prevIndex],
117
+ to: decision.model,
118
+ pctConsumed: pct,
119
+ });
120
+ }
121
+ }
122
+ // ── Pre-flight: check limits before burning tokens ────────────────────────
123
+ // Runs AFTER routing so fallbackChainExhausted takes priority over generic cost.
124
+ this._checkOrThrow(this.tracker.snapshot());
125
+ // ── Fetch live pricing for this model ─────────────────────────────────────
126
+ const pricingSpan = this._startSpan('agent-budget.pricing');
127
+ const pricing = await getModelPricing(request.model, this.apiKey, this.cacheTTL, (modelCount, cachedUntil) => {
128
+ this.emitter.emit({ type: 'pricing:fetched', modelCount, cachedUntil });
129
+ });
130
+ pricingSpan?.end();
131
+ // ── Pre-flight cost estimation ────────────────────────────────────────────
132
+ if (this.limits.preflightCheck !== false && this.limits.maxCostUSD !== undefined) {
133
+ const estimate = estimateStepCost(request, pricing, this.limits.preflightOutputTokenEstimate ?? 512);
134
+ const usage = this.tracker.snapshot();
135
+ const remainingBudget = this.limits.maxCostUSD - usage.totalCostUSD;
136
+ if (estimate.estimatedCostUSD > remainingBudget) {
137
+ const exceeded = {
138
+ reason: 'preflightCostEstimate',
139
+ limit: this.limits.maxCostUSD,
140
+ actual: usage.totalCostUSD,
141
+ usage,
142
+ remainingBudget,
143
+ estimatedCost: estimate.estimatedCostUSD,
144
+ };
145
+ this.emitter.emit({ type: 'budget:exceeded', exceeded });
146
+ if (typeof this.onExceeded === 'function') {
147
+ this.onExceeded(usage);
148
+ }
149
+ throw new BudgetError(exceeded);
150
+ }
151
+ }
152
+ // ── Auto-compress messages if approaching token threshold ────────────────
153
+ if (this.autoCompress) {
154
+ const estimatedTokens = estimateMessagesTokens(request.messages);
155
+ if (estimatedTokens > this.autoCompress.thresholdTokens) {
156
+ const messagesBefore = request.messages.length;
157
+ request.messages = await compressMessageHistory(request.messages, this.apiKey, this.autoCompress.keepLastN ?? 4);
158
+ const messagesAfter = request.messages.length;
159
+ this.emitter.emit({
160
+ type: 'compress:triggered',
161
+ messagesBefore,
162
+ messagesAfter,
163
+ tokensFreed: estimatedTokens - estimateMessagesTokens(request.messages),
164
+ });
165
+ }
166
+ }
167
+ // ── Emit step:start before API call ─────────────────────────────────────
168
+ this.emitter.emit({
169
+ type: 'step:start',
170
+ stepIndex,
171
+ model: request.model,
172
+ });
173
+ // ── Execute the step (custom executor or built-in OpenRouter fetch) ──────
174
+ let response;
175
+ if (this.executor) {
176
+ const result = await this.executor({ ...request });
177
+ response = {
178
+ id: '',
179
+ model: result.model,
180
+ choices: result.choices.map(c => ({
181
+ message: c.message,
182
+ finish_reason: c.finish_reason,
183
+ })),
184
+ usage: result.usage,
185
+ };
186
+ }
187
+ else {
188
+ response = await this._defaultFetch(request, stepIndex, pricing);
189
+ }
190
+ const durationMs = Date.now() - stepStart;
191
+ // ── Record this step (before checks so circuit breaker can analyze it) ─────
192
+ const inputTokens = response.usage?.prompt_tokens ?? 0;
193
+ const outputTokens = response.usage?.completion_tokens ?? 0;
194
+ const costUSD = calculateCost(pricing, inputTokens, outputTokens);
195
+ const outputContent = response.choices?.[0]?.message?.content ?? '';
196
+ this.tracker.record({
197
+ stepIndex: this.tracker.stepCount(),
198
+ model: request.model,
199
+ inputTokens,
200
+ outputTokens,
201
+ costUSD,
202
+ durationMs,
203
+ outputContent,
204
+ });
205
+ // ── Emit step:end ───────────────────────────────────────────────────────
206
+ this.emitter.emit({
207
+ type: 'step:end',
208
+ stepIndex,
209
+ model: request.model,
210
+ inputTokens,
211
+ outputTokens,
212
+ costUSD,
213
+ durationMs,
214
+ });
215
+ // ── Circuit breaker: check for repetition or stagnation ──────────────────
216
+ if (this.circuitBreaker) {
217
+ const trip = this.circuitBreaker.check(this.tracker.snapshot());
218
+ if (trip) {
219
+ const usage = this.tracker.snapshot();
220
+ const exceeded = {
221
+ reason: 'circuitBreaker',
222
+ limit: 0,
223
+ actual: 0,
224
+ usage,
225
+ triggerMode: trip.triggerMode,
226
+ windowSize: trip.windowSize,
227
+ similarity: trip.similarity,
228
+ };
229
+ this.emitter.emit({
230
+ type: 'circuit:tripped',
231
+ triggerMode: trip.triggerMode,
232
+ stepIndex,
233
+ });
234
+ this.emitter.emit({ type: 'budget:exceeded', exceeded });
235
+ if (typeof this.onExceeded === 'function') {
236
+ this.onExceeded(usage);
237
+ }
238
+ this.tracker.rollback();
239
+ throw new BudgetError(exceeded);
240
+ }
241
+ }
242
+ // ── Checkpoint: write after step succeeds ────────────────────────────────
243
+ if (this.checkpointManager) {
244
+ const responseMessage = response.choices?.[0]?.message;
245
+ const checkpointMessages = responseMessage
246
+ ? [...request.messages, responseMessage]
247
+ : request.messages;
248
+ await this.checkpointManager.save(checkpointMessages, this.tracker.snapshot(), request.model, this.tracker.stepCount());
249
+ }
250
+ // ── Post-step: check all limits including updated cost + token totals ──────
251
+ try {
252
+ this._checkOrThrow(this.tracker.snapshot());
253
+ }
254
+ catch (err) {
255
+ // Roll back the tracker so the consumer can retry without stale data.
256
+ // The actual API spend is included in the error for transparency.
257
+ this.tracker.rollback();
258
+ throw err;
259
+ }
260
+ stepSpan?.end();
261
+ return response;
262
+ }
263
+ /**
264
+ * Current accumulated usage. Safe to call at any time.
265
+ */
266
+ getUsage() {
267
+ return this.tracker.snapshot();
268
+ }
269
+ /**
270
+ * Prints a single summary table to console. Returns the same usage snapshot.
271
+ */
272
+ summary() {
273
+ const u = this.tracker.snapshot();
274
+ const models = [...new Set(u.stepHistory.map(s => s.model))];
275
+ const costPerStep = u.steps > 0 ? u.totalCostUSD / u.steps : 0;
276
+ const durSec = (u.elapsedMs / 1000).toFixed(1);
277
+ console.log('┌──────────────────────────────────────────────┐');
278
+ console.log('│ agent-budget summary │');
279
+ console.log('├──────────────────────────────────────────────┤');
280
+ console.log(`│ Steps: ${String(u.steps).padStart(10)} │`);
281
+ console.log(`│ Cost: $${u.totalCostUSD.toFixed(6).padStart(10)} │`);
282
+ console.log(`│ Cost/step: $${costPerStep.toFixed(6).padStart(10)} │`);
283
+ console.log(`│ Input tokens: ${String(u.totalInputTokens).padStart(10)} │`);
284
+ console.log(`│ Output tokens:${String(u.totalOutputTokens).padStart(10)} │`);
285
+ console.log(`│ Duration: ${durSec.padStart(7)}s │`);
286
+ console.log(`│ Model: ${models.join(', ').slice(0, 30).padEnd(30)} │`);
287
+ console.log('└──────────────────────────────────────────────┘');
288
+ return u;
289
+ }
290
+ /**
291
+ * Subscribe to a specific event type.
292
+ */
293
+ on(type, handler) {
294
+ this.emitter.on(type, handler);
295
+ return this;
296
+ }
297
+ /**
298
+ * Unsubscribe from a specific event type.
299
+ */
300
+ off(type, handler) {
301
+ this.emitter.off(type, handler);
302
+ return this;
303
+ }
304
+ /**
305
+ * Resets all usage counters. Does NOT reset pricing cache.
306
+ */
307
+ reset() {
308
+ this.tracker.reset();
309
+ this.warningChecker.reset();
310
+ }
311
+ /**
312
+ * Force-refresh pricing on next step. Useful for long-running agents.
313
+ */
314
+ refreshPricing() {
315
+ invalidatePricingCache();
316
+ }
317
+ /**
318
+ * Returns the model that the adaptive router would use right now,
319
+ * or undefined if adaptive routing is not configured.
320
+ */
321
+ getCurrentModel() {
322
+ if (!this.adaptiveRouting)
323
+ return undefined;
324
+ return this.adaptiveRouting.fallbackChain[this.currentModelIndex];
325
+ }
326
+ /**
327
+ * Manually record a step into the tracker.
328
+ * Useful for replaying checkpoints or simulating usage in tests.
329
+ */
330
+ recordStep(usage) {
331
+ this.tracker.record({
332
+ stepIndex: this.tracker.stepCount(),
333
+ model: this.getCurrentModel() ?? 'unknown',
334
+ inputTokens: usage.inputTokens,
335
+ outputTokens: usage.outputTokens,
336
+ costUSD: usage.costUSD,
337
+ durationMs: 0,
338
+ });
339
+ }
340
+ /**
341
+ * Manually compress a message array. Useful outside of the step() flow.
342
+ * Preserves the system message (if any) and the last `keepLastN` messages.
343
+ * Everything in between is summarized via an LLM call.
344
+ */
345
+ async compressMessages(messages, keepLastN) {
346
+ return compressMessageHistory(messages, this.apiKey, keepLastN ?? this.autoCompress?.keepLastN ?? 4);
347
+ }
348
+ /**
349
+ * Delete the checkpoint file. Call after the agent loop completes successfully.
350
+ */
351
+ async clearCheckpoint() {
352
+ await this.checkpointManager?.clear();
353
+ }
354
+ /**
355
+ * Load an existing checkpoint. Returns null if none exists.
356
+ */
357
+ async loadCheckpoint() {
358
+ return this.checkpointManager?.load() ?? null;
359
+ }
360
+ /**
361
+ * Resume from a checkpoint. Constructs a new AgentBudget with tracker state
362
+ * pre-loaded so budget accounting continues from where it left off.
363
+ * Throws if no checkpoint file exists.
364
+ */
365
+ static async resume(options, checkpointPath) {
366
+ const path = options.checkpoint?.path ?? checkpointPath ?? './.agent-checkpoint.json';
367
+ const manager = new CheckpointManager({ path });
368
+ const data = await manager.load();
369
+ if (!data) {
370
+ throw new Error(`[agent-budget] No checkpoint found at ${path}`);
371
+ }
372
+ const agent = new AgentBudget(options);
373
+ agent.tracker = UsageTracker.fromSnapshot(data.usage);
374
+ return agent;
375
+ }
376
+ // ── Internal ────────────────────────────────────────────────────────────────
377
+ _initTracer() {
378
+ if (!this.telemetry?.enabled || this.tracer)
379
+ return;
380
+ try {
381
+ const otel = require('@opentelemetry/api');
382
+ this.tracer = otel.trace.getTracer('agent-budget');
383
+ }
384
+ catch {
385
+ this.tracer = null;
386
+ }
387
+ }
388
+ _startSpan(name) {
389
+ if (!this.tracer)
390
+ return null;
391
+ try {
392
+ const span = this.tracer.startSpan(name);
393
+ return {
394
+ end: () => { try {
395
+ span.end();
396
+ }
397
+ catch { } }
398
+ };
399
+ }
400
+ catch {
401
+ return null;
402
+ }
403
+ }
404
+ async _readStream(res, model, stepIndex, stepStart, pricing) {
405
+ const reader = res.body.getReader();
406
+ const decoder = new TextDecoder();
407
+ let buffer = '';
408
+ let fullContent = '';
409
+ let responseId = '';
410
+ let responseModel = '';
411
+ let usage = null;
412
+ let streamError = null;
413
+ while (true) {
414
+ const { done, value } = await reader.read();
415
+ if (done)
416
+ break;
417
+ buffer += decoder.decode(value, { stream: true });
418
+ const lines = buffer.split('\n');
419
+ buffer = lines.pop() ?? '';
420
+ for (const line of lines) {
421
+ const trimmed = line.trim();
422
+ if (!trimmed.startsWith('data: '))
423
+ continue;
424
+ const data = trimmed.slice(6);
425
+ if (data === '[DONE]')
426
+ continue;
427
+ try {
428
+ const parsed = JSON.parse(data);
429
+ const choice = parsed.choices?.[0];
430
+ // Check for streamed provider error (finish_reason: "error" + choice.error)
431
+ if (choice?.error) {
432
+ streamError = { code: choice.error.code, message: choice.error.message };
433
+ }
434
+ // Emit token for each content delta
435
+ if (choice?.delta?.content) {
436
+ const token = choice.delta.content;
437
+ fullContent += token;
438
+ this.emitter.emit({ type: 'step:token', stepIndex, token });
439
+ }
440
+ // Capture usage — it appears in the final chunk before [DONE].
441
+ // With stream_options: { include_usage: true }, this chunk has
442
+ // an empty choices array and a usage object.
443
+ if (parsed.usage) {
444
+ usage = parsed.usage;
445
+ }
446
+ if (parsed.id)
447
+ responseId = parsed.id;
448
+ if (parsed.model)
449
+ responseModel = parsed.model;
450
+ }
451
+ catch { /* skip malformed SSE lines */ }
452
+ }
453
+ }
454
+ // If a streamed error was captured, throw it so the budget layer
455
+ // doesn't record a fake zero-cost step.
456
+ if (streamError) {
457
+ throw new UpstreamError(streamError.code, streamError.message);
458
+ }
459
+ return {
460
+ id: responseId,
461
+ model: responseModel || model,
462
+ choices: [{
463
+ message: { role: 'assistant', content: fullContent },
464
+ finish_reason: 'stop',
465
+ }],
466
+ usage: usage ?? { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
467
+ };
468
+ }
469
+ async _defaultFetch(request, stepIndex, pricing) {
470
+ const headers = {
471
+ ...this.defaultHeaders,
472
+ Authorization: `Bearer ${this.apiKey}`,
473
+ 'Content-Type': 'application/json',
474
+ };
475
+ if (this.siteUrl)
476
+ headers['HTTP-Referer'] = this.siteUrl;
477
+ if (this.appTitle)
478
+ headers['X-OpenRouter-Title'] = this.appTitle;
479
+ // When streaming, include stream_options so usage data is returned
480
+ // in the final chunk. Respect any user-supplied value.
481
+ const body = { ...request };
482
+ if (body.stream === true && !('stream_options' in body)) {
483
+ body.stream_options = { include_usage: true };
484
+ }
485
+ const url = `${this.baseUrl.replace(/\/+$/, '')}/chat/completions`;
486
+ let res;
487
+ const MAX_RETRIES = 3;
488
+ for (let attempt = 0;; attempt++) {
489
+ res = await fetch(url, {
490
+ method: 'POST',
491
+ headers,
492
+ body: JSON.stringify(body),
493
+ });
494
+ if (res.status === 429 && attempt < MAX_RETRIES) {
495
+ const retryAfter = parseInt(res.headers.get('retry-after') ?? '0', 10) || 0;
496
+ const backoff = retryAfter > 0
497
+ ? retryAfter * 1000
498
+ : Math.min(1000 * 2 ** attempt, 30000);
499
+ console.warn(`[agent-budget] Rate limited (429). Retrying in ${backoff}ms (attempt ${attempt + 1}/${MAX_RETRIES})`);
500
+ await new Promise(r => setTimeout(r, backoff));
501
+ continue;
502
+ }
503
+ break;
504
+ }
505
+ if (!res.ok) {
506
+ const bodyText = await res.text();
507
+ if (res.status === 402) {
508
+ throw new Error(`[agent-budget] Insufficient credits (402): ${bodyText}`);
509
+ }
510
+ if (res.status === 502) {
511
+ throw new Error(`[agent-budget] Provider unavailable (502): ${bodyText}`);
512
+ }
513
+ if (res.status === 429) {
514
+ const retryAfter = parseInt(res.headers.get('retry-after') ?? '0', 10) || 0;
515
+ throw new RateLimitError(429, retryAfter, `[agent-budget] Rate limit exceeded after ${MAX_RETRIES} retries: ${bodyText}`);
516
+ }
517
+ throw new Error(`[agent-budget] API error ${res.status}: ${bodyText}`);
518
+ }
519
+ const response = request.stream === true
520
+ ? await this._readStream(res, request.model, stepIndex, Date.now(), pricing)
521
+ : (await res.json());
522
+ // OpenRouter may return HTTP 200 with an error inside choices[0].
523
+ // This happens when the provider rejects the request (insufficient
524
+ // credits, guardrail, provider outage, etc.).
525
+ const choiceError = response.choices?.[0]?.error;
526
+ if (choiceError) {
527
+ throw new UpstreamError(choiceError.code, choiceError.message, choiceError.metadata);
528
+ }
529
+ return response;
530
+ }
531
+ _checkOrThrow(usage) {
532
+ const exceeded = checkLimits(usage, this.limits);
533
+ if (exceeded) {
534
+ this.emitter.emit({ type: 'budget:exceeded', exceeded });
535
+ if (typeof this.onExceeded === 'function') {
536
+ this.onExceeded(usage);
537
+ }
538
+ throw new BudgetError(exceeded);
539
+ }
540
+ this.warningChecker.check(usage, this.limits, this.warningThreshold, (event) => {
541
+ this.emitter.emit(event);
542
+ });
543
+ }
544
+ }
545
+ // ─── Convenience factory ─────────────────────────────────────────────────────
546
+ export function createAgentBudget(options) {
547
+ return new AgentBudget(options);
548
+ }
549
+ // ─── Re-exports ───────────────────────────────────────────────────────────────
550
+ export { BudgetError, RateLimitError, UpstreamError } from './budget.js';
551
+ export { getModelPricing, calculateCost, invalidatePricingCache, setModelPricing } from './pricing.js';
552
+ export { estimateStepCost } from './estimator.js';
553
+ export { CircuitBreaker } from './circuit-breaker.js';
554
+ export { resolveModel } from './router.js';
555
+ export { CheckpointManager } from './checkpoint.js';
556
+ export { compressMessages, estimateMessagesTokens } from './compressor.js';
557
+ export { AgentEventEmitter } from './events.js';
@@ -0,0 +1,19 @@
1
+ import type { ModelPricing } from './types.js';
2
+ /**
3
+ * Returns pricing for a model. Fetches all model prices from OpenRouter once,
4
+ * then caches for `cacheTTLMs`. Unknown models return zero-cost (with a warning).
5
+ */
6
+ export declare function getModelPricing(modelId: string, apiKey: string, cacheTTLMs: number, onFreshFetch?: (modelCount: number, cachedUntil: number) => void): Promise<ModelPricing>;
7
+ /**
8
+ * Computes USD cost from pricing + token counts.
9
+ */
10
+ export declare function calculateCost(pricing: ModelPricing, inputTokens: number, outputTokens: number): number;
11
+ /**
12
+ * Force-invalidates the pricing cache. Call this if you need fresh prices mid-run.
13
+ */
14
+ export declare function invalidatePricingCache(): void;
15
+ /**
16
+ * Override pricing for a specific model. Used for testing with simulated costs.
17
+ * Does NOT persist across cache invalidations.
18
+ */
19
+ export declare function setModelPricing(modelId: string, pricing: ModelPricing): void;
@@ -0,0 +1,81 @@
1
+ // ─── Module-level cache (shared across all AgentBudget instances) ─────────────
2
+ let cache = null;
3
+ // ─── Public ───────────────────────────────────────────────────────────────────
4
+ /**
5
+ * Returns pricing for a model. Fetches all model prices from OpenRouter once,
6
+ * then caches for `cacheTTLMs`. Unknown models return zero-cost (with a warning).
7
+ */
8
+ export async function getModelPricing(modelId, apiKey, cacheTTLMs, onFreshFetch) {
9
+ const now = Date.now();
10
+ if (!cache || now - cache.fetchedAt > cacheTTLMs) {
11
+ try {
12
+ cache = await fetchAllPricing(apiKey, now);
13
+ onFreshFetch?.(cache.data.size, cache.fetchedAt + cacheTTLMs);
14
+ }
15
+ catch (err) {
16
+ // If fetch fails, use existing cache (even if expired) rather than throwing.
17
+ // If no cache exists at all, return zero pricing silently.
18
+ if (cache) {
19
+ console.warn(`[agent-budget] Failed to refresh pricing cache, using stale data: ${err.message?.split('\n')[0]}`);
20
+ }
21
+ else {
22
+ console.warn(`[agent-budget] Failed to fetch pricing for "${modelId}". Cost tracking will be 0.`);
23
+ return { promptPerToken: 0, completionPerToken: 0 };
24
+ }
25
+ }
26
+ }
27
+ const pricing = cache.data.get(modelId);
28
+ if (!pricing) {
29
+ console.warn(`[agent-budget] No pricing data for model "${modelId}". ` +
30
+ `Cost tracking will be 0 for this model. ` +
31
+ `Check https://openrouter.ai/models for the exact model slug.`);
32
+ return { promptPerToken: 0, completionPerToken: 0 };
33
+ }
34
+ return pricing;
35
+ }
36
+ /**
37
+ * Computes USD cost from pricing + token counts.
38
+ */
39
+ export function calculateCost(pricing, inputTokens, outputTokens) {
40
+ return pricing.promptPerToken * inputTokens + pricing.completionPerToken * outputTokens;
41
+ }
42
+ /**
43
+ * Force-invalidates the pricing cache. Call this if you need fresh prices mid-run.
44
+ */
45
+ export function invalidatePricingCache() {
46
+ cache = null;
47
+ }
48
+ /**
49
+ * Override pricing for a specific model. Used for testing with simulated costs.
50
+ * Does NOT persist across cache invalidations.
51
+ */
52
+ export function setModelPricing(modelId, pricing) {
53
+ if (!cache) {
54
+ cache = { data: new Map(), fetchedAt: Date.now() };
55
+ }
56
+ cache.data.set(modelId, pricing);
57
+ }
58
+ // ─── Private ──────────────────────────────────────────────────────────────────
59
+ async function fetchAllPricing(apiKey, fetchedAt) {
60
+ const res = await fetch('https://openrouter.ai/api/v1/models', {
61
+ headers: { Authorization: `Bearer ${apiKey}` },
62
+ });
63
+ if (!res.ok) {
64
+ throw new Error(`[agent-budget] Failed to fetch OpenRouter model list: ${res.status} ${res.statusText}`);
65
+ }
66
+ const json = (await res.json());
67
+ const data = new Map();
68
+ for (const model of json.data) {
69
+ if (!model.pricing)
70
+ continue;
71
+ const prompt = parseFloat(model.pricing.prompt);
72
+ const completion = parseFloat(model.pricing.completion);
73
+ if (!isNaN(prompt) && !isNaN(completion)) {
74
+ data.set(model.id, {
75
+ promptPerToken: prompt,
76
+ completionPerToken: completion,
77
+ });
78
+ }
79
+ }
80
+ return { data, fetchedAt };
81
+ }
@@ -0,0 +1,14 @@
1
+ import type { BudgetUsage } from './types.js';
2
+ export interface RoutingDecision {
3
+ model: string;
4
+ index: number;
5
+ }
6
+ /**
7
+ * Resolves which model to use from the fallback chain based on current
8
+ * budget consumption. Returns the model and its index in the chain.
9
+ */
10
+ export declare function resolveModel(fallbackChain: string[], thresholds: number[] | undefined, usage: BudgetUsage, maxCostUSD: number | undefined): RoutingDecision;
11
+ /**
12
+ * Checks if a downgrade occurred and returns logging info.
13
+ */
14
+ export declare function shouldLogDowngrade(prevIndex: number, currentIndex: number): boolean;