@theokit/sdk-budget 0.3.2 → 0.3.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.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { BudgetTracker } from '@theokit/sdk';
1
+ import { BudgetMode, BudgetSnapshot, BudgetHandle, BudgetOptions, TokenUsage, BudgetWindow, BudgetTracker } from '@theokit/sdk';
2
2
 
3
3
  /**
4
4
  * M7-6 — honest-null cost render helper. Turns the `number | undefined` cost
@@ -38,9 +38,35 @@ declare function formatCostUsd(cost: number | undefined, opts?: FormatCostUsdOpt
38
38
  *
39
39
  * @internal
40
40
  */
41
+ /**
42
+ * Midnight UTC of `now`'s day — the start of a `1d` budget window.
43
+ *
44
+ * Always UTC, never the host's local timezone: "1 USD per day" resets at 00:00Z, so a budget in
45
+ * UTC-05:00 resets at 19:00 local. That is deliberate — a budget shared by processes in different
46
+ * regions must reset at one instant — but it surprises anyone reading a daily total at local
47
+ * midnight.
48
+ */
41
49
  declare function startOfDayUtc(now?: Date): Date;
50
+ /**
51
+ * Midnight UTC on the MONDAY of `now`'s week — the start of a `1w` budget window.
52
+ *
53
+ * ISO 8601, so the week starts on Monday, not Sunday. This is calendar-aligned rather than rolling:
54
+ * "5 USD per week" resets on Monday, which is what a person reading a weekly budget expects, and not
55
+ * a trailing 168 hours. `30d` and `365d` are relative for the mirror-image reason — nobody expects a
56
+ * monthly budget to reset on the 1st.
57
+ */
42
58
  declare function startOfWeekUtc(now?: Date): Date;
43
- /** Returns the inclusive start timestamp (ms) for the given window relative to `now`. */
59
+ /**
60
+ * Inclusive start timestamp (ms) of `window`, as `spentIn` uses it to decide which charges count.
61
+ *
62
+ * Two different behaviours behind one enum, and the difference is visible in every total:
63
+ *
64
+ * - `1d` / `1w` are CALENDAR-aligned to UTC (midnight; ISO Monday). Spend resets at a boundary.
65
+ * - `1h` / `30d` / `365d` are ROLLING — `now` minus the duration. Nothing ever "resets"; the
66
+ * oldest charges simply fall out of the window.
67
+ *
68
+ * Throws on a value outside `BudgetWindow`, which TypeScript already prevents.
69
+ */
44
70
  declare function windowStartMs(window: BudgetWindow, now?: Date): number;
45
71
 
46
72
  /**
@@ -56,23 +82,47 @@ declare function windowStartMs(window: BudgetWindow, now?: Date): number;
56
82
  * @internal
57
83
  */
58
84
  /**
59
- * Throws BudgetExceededError if `mode === "block"` and any limit
60
- * would be exceeded. No-op for audit/warn modes (post-charge checks
61
- * handle those).
85
+ * Refuse an upcoming call that would push a `"block"`-mode budget past one of its limits. Call it
86
+ * BEFORE the LLM request, with your cost estimate for that request.
62
87
  *
63
- * Caller invokes this BEFORE the LLM call.
88
+ * Throws `BudgetExceededError` (carrying `budgetName`, `window`, the projected `spentUsd`, the
89
+ * `limitUsd` and `mode: "block"`) for the FIRST limit where `alreadySpent + estimatedUsd` exceeds
90
+ * the limit. The comparison is strict, so landing exactly on the limit is allowed through — while
91
+ * the post-charge `onExceed` callback fires at `>=`. The two boundaries do not agree.
92
+ *
93
+ * SILENTLY DOES NOTHING in three cases, and none of them is distinguishable from "you are within
94
+ * budget" at the call site:
95
+ *
96
+ * - `name` is not registered — a typo, or a budget deleted since. Nothing is enforced.
97
+ * - the budget's mode is `"warn"` or `"audit"` — those never block by design.
98
+ * - the estimate is low. Enforcement is only as good as `estimatedUsd`; passing 0 disables it.
64
99
  */
65
100
  declare function preflightCheck(name: string, estimatedUsd: number): void;
66
101
  /**
67
- * Charge the budget + dispatch threshold/exceed callbacks (EC-8 isolated).
102
+ * Record the ACTUAL cost of a completed call against a budget and fire its callbacks. Call it after
103
+ * the LLM request, paired with `preflightCheck` before it.
104
+ *
105
+ * NEVER THROWS on budget state — including when the charge tips a limit. Enforcement happens on the
106
+ * next `preflightCheck`; this one only records and notifies. A callback that throws is caught and
107
+ * logged to stderr, so your `onThreshold` / `onExceed` cannot break the run either.
108
+ *
109
+ * Per mode:
110
+ *
111
+ * - `"audit"` — charge only, no callbacks.
112
+ * - `"warn"` — charge, then `onThreshold` at 80% or 95%, then `onExceed` at 100%. With no
113
+ * `onExceed` handler it writes a line to stderr instead.
68
114
  *
69
- * - In `audit` mode: charge only, no callbacks.
70
- * - In `warn` mode: charge + onThreshold (80/95) + onExceed (100). No throw.
71
- * - In `block` mode: charge + onThreshold + onExceed. No throw post-call
72
- * (preflightCheck already prevented exceed for the upcoming call;
73
- * this protects against simultaneous-call races where multiple sends
74
- * each pass preflight independently last one to charge may still
75
- * tip a limit. We document the case rather than retroactively throw).
115
+ * Per limit, exactly ONE callback fires per call: the highest matched threshold, or `onExceed`,
116
+ * never both. But there is NO de-duplication across calls a budget sitting at 90% fires
117
+ * `onThreshold(0.8)` again on every single charge, and one past its limit fires `onExceed` on
118
+ * every charge forever. Debounce inside your handler if it pages anyone. A budget with three
119
+ * limits evaluates all three, so one call can fire three callbacks.
120
+ * - `"block"`same callbacks as `"warn"`, still no throw. Concurrent sends each pass their own
121
+ * preflight, so the last to charge can legitimately tip a limit that preflight had cleared.
122
+ *
123
+ * A DELETED OR UNKNOWN BUDGET LOSES THE CHARGE. The function writes a warning to stderr and
124
+ * returns — the spend is not recorded anywhere, so a budget removed mid-flight under-reports rather
125
+ * than over-reports. This is the one path where the ledger and reality diverge on purpose.
76
126
  */
77
127
  declare function chargeAndCheckThresholds(name: string, actualUsd: number): Promise<void>;
78
128
 
@@ -90,9 +140,40 @@ declare function chargeAndCheckThresholds(name: string, actualUsd: number): Prom
90
140
  *
91
141
  * @internal
92
142
  */
93
- /** Charge a budget. Idempotent across concurrent calls via withCwdMutex. */
143
+ /**
144
+ * Append `amountUsd` to the spend ledger for the budget called `name`, timestamped now.
145
+ *
146
+ * NOT idempotent. `withCwdMutex` SERIALIZES concurrent calls so two appends cannot interleave; it
147
+ * does not deduplicate them. Calling this twice with the same arguments records the spend twice,
148
+ * and there is no charge id to reconcile against — an at-least-once caller needs its own guard.
149
+ *
150
+ * NOT VALIDATED against the registry either: charging a name that was never passed to
151
+ * `createBudget` (a typo, say) succeeds silently and accumulates spend that no limit enforces and
152
+ * that `snapshotAll` — which iterates the REGISTRY — will never show.
153
+ *
154
+ * `chargeAndCheckThresholds` does not rescue you from that, and swapping to it trades one silent
155
+ * failure for another: on an unknown or already-deleted name it writes one line to stderr and
156
+ * RETURNS WITHOUT CHARGING, so the spend is lost rather than merely invisible. Neither function
157
+ * throws. Validate the name against `getBudgetOptionsRaw` / `getBudget` if it matters which of the
158
+ * two failures you get.
159
+ *
160
+ * `amountUsd <= 0` is a no-op, so a refund cannot be expressed here.
161
+ *
162
+ * The ledger is a MODULE-LEVEL SINGLETON: process-wide, shared by every budget and every agent in
163
+ * the process, and lost on exit — there is no persistence across restarts. Entries older than a
164
+ * year are garbage-collected opportunistically.
165
+ */
94
166
  declare function charge(name: string, amountUsd: number): Promise<void>;
95
- /** Return total spend in the given window for `name`. Snapshot read (no mutex needed). */
167
+ /**
168
+ * Total USD recorded for `name` inside `window`, summed at call time.
169
+ *
170
+ * Returns `0` for a name that was never charged — indistinguishable from a budget that exists and
171
+ * has spent nothing. Use `getBudget(name)` when you need to tell those apart.
172
+ *
173
+ * Lock-free: it reads the live array without taking the mutex, so a charge landing mid-read is
174
+ * simply included or not. Window boundaries are those of {@link windowStartMs} — `1d` and `1w` are
175
+ * UTC calendar-aligned, the rest are rolling.
176
+ */
96
177
  declare function spentIn(name: string, window: BudgetWindow, now?: Date): number;
97
178
 
98
179
  /**
@@ -117,7 +198,34 @@ declare function spentIn(name: string, window: BudgetWindow, now?: Date): number
117
198
  * @internal
118
199
  */
119
200
  type ApiMode = "anthropic_messages" | "openai_chat_completions" | "openai_responses";
201
+ /**
202
+ * Which usage dialect a provider reports in, inferred from its name.
203
+ *
204
+ * Providers do not agree on the shape of a usage object: Anthropic reports `input_tokens` /
205
+ * `output_tokens`, OpenAI's Responses API reports yet another, and the large
206
+ * OpenAI-chat-compatible family (openai, openrouter, deepseek, google, ollama, lmstudio, …) shares
207
+ * one. This maps a provider name onto that choice.
208
+ *
209
+ * UNKNOWN NAMES FALL BACK to the OpenAI chat-completions shape, because that is what almost every
210
+ * compatible endpoint speaks — a new proxy usually works without a change here. Pass
211
+ * {@link normalizeUsage}'s `apiMode` explicitly when a provider is compatible in its wire format but
212
+ * not in its name.
213
+ */
120
214
  declare function inferApiMode(provider: string): ApiMode;
215
+ /**
216
+ * Turn a provider's raw usage object into the SDK's canonical {@link TokenUsage}.
217
+ *
218
+ * NEVER THROWS, and that is the point: usage arrives from a third party at the end of a successful
219
+ * call, so a missing or malformed field must not fail a request the model already answered. `null`,
220
+ * `undefined` and non-objects all yield an all-zero usage, and unrecognised fields are dropped.
221
+ *
222
+ * The cost of that tolerance is that a zero is ambiguous — it means "the provider reported nothing
223
+ * usable" as readily as "no tokens". A budget that suddenly stops accruing is the symptom of a
224
+ * dialect mismatch, not of free traffic.
225
+ *
226
+ * `apiMode` overrides the guess {@link inferApiMode} makes from the provider name; supply it for a
227
+ * compatible endpoint whose name is not recognised.
228
+ */
121
229
  declare function normalizeUsage(rawUsage: unknown, opts: {
122
230
  provider: string;
123
231
  apiMode?: ApiMode;
@@ -129,12 +237,81 @@ declare function normalizeUsage(rawUsage: unknown, opts: {
129
237
  *
130
238
  * @internal
131
239
  */
240
+ /**
241
+ * Register a budget under `opts.name` and return its live handle.
242
+ *
243
+ * ```ts
244
+ * const b = createBudget({ name: "daily", mode: "block", limits: [{ window: "1d", limitUsd: 5 }] });
245
+ * ```
246
+ *
247
+ * THROWS `ConfigurationError(code: "invalid_budget_name")` in two cases: a name that does not match
248
+ * `^[a-z0-9][a-z0-9_-]*$` (lowercase only — `"Daily"` and `"my.budget"` are rejected), and a name
249
+ * already registered. Duplicate registration is deliberately an error rather than an idempotent
250
+ * return, so a second `createBudget("daily", …)` with different limits cannot silently win.
251
+ *
252
+ * Registering does NOT reset spend. The ledger is keyed by NAME and outlives the registry entry, so
253
+ * re-creating a budget after `deleteBudget` inherits everything charged under that name — a
254
+ * config reload keeps enforcing the day's spend, which is usually right and is a surprise if you
255
+ * expected a fresh start.
256
+ *
257
+ * Process-local and non-persistent: nothing survives a restart, so re-create budgets at startup.
258
+ * `mode` defaults to `"warn"` (see {@link defaultMode}) — a budget created without one observes and
259
+ * does not block.
260
+ */
132
261
  declare function createBudget(opts: BudgetOptions): BudgetHandle;
262
+ /**
263
+ * The live handle for a registered budget, or `undefined` when the name is unknown.
264
+ *
265
+ * `undefined` is the honest answer for "never created" — it does NOT mean "zero spend". A budget
266
+ * that exists but has not been charged returns a handle whose `spentIn(...)` is 0, and the two are
267
+ * different facts: the first means nothing is enforcing a limit.
268
+ *
269
+ * The registry is per-PROCESS and holds no persistence, so a fresh process starts with no budgets
270
+ * and no ledger. Re-create them at startup.
271
+ */
133
272
  declare function getBudget(name: string): BudgetHandle | undefined;
273
+ /**
274
+ * Every budget registered in this process, in insertion order.
275
+ *
276
+ * Empty after a restart — see {@link getBudget} on process-local state. Use it to enumerate what is
277
+ * being enforced; use {@link snapshotAll} when you want the numbers rather than the handles.
278
+ */
134
279
  declare function listBudgets(): readonly BudgetHandle[];
280
+ /**
281
+ * Remove a budget from the registry. Returns `false` when the name was not registered.
282
+ *
283
+ * This stops ENFORCEMENT; it does not refund or clear the ledger. Re-creating a budget under the
284
+ * same name inherits the spend already recorded for that name, which is usually what you want after
285
+ * a config reload and a surprise if you were expecting a reset.
286
+ */
135
287
  declare function deleteBudget(name: string): boolean;
288
+ /**
289
+ * One row per budget PER WINDOW — a budget with three limits produces three rows, not one.
290
+ *
291
+ * `ratio` is `spentUsd / limitUsd`, and is 0 when the limit is 0 rather than `Infinity`, so a
292
+ * zero-limit budget does not poison a dashboard that sums or charts these. Spend is computed at call
293
+ * time from the ledger, counting only entries inside each window, so the same budget reports
294
+ * different numbers for `1d` and `30d`.
295
+ */
136
296
  declare function snapshotAll(): readonly BudgetSnapshot[];
297
+ /**
298
+ * The stored {@link BudgetOptions} exactly as registered, or `undefined` when the name is unknown.
299
+ *
300
+ * Distinct from {@link getBudget}, which returns a HANDLE with live accessors. Use this when you
301
+ * need the configuration itself — the limits array, the callbacks, the declared mode — for example
302
+ * to render it or to re-register the same shape elsewhere.
303
+ *
304
+ * The object is the registry's own, held by reference: mutating it changes what the budget enforces
305
+ * without going through `Budget.create`, which is a bug waiting to happen. Copy before editing.
306
+ */
137
307
  declare function getBudgetOptionsRaw(name: string): BudgetOptions | undefined;
308
+ /**
309
+ * The mode a budget enforces, resolving the omitted case to `"warn"`.
310
+ *
311
+ * `"warn"` fires the callbacks and lets the call through; `"block"` refuses it. The default is
312
+ * deliberate: a budget added for observability must not start rejecting traffic because someone
313
+ * forgot a field.
314
+ */
138
315
  declare function defaultMode(opts: BudgetOptions): BudgetMode;
139
316
 
140
317
  /**
@@ -150,13 +327,36 @@ declare function defaultMode(opts: BudgetOptions): BudgetMode;
150
327
  *
151
328
  * @public
152
329
  */
330
+ /**
331
+ * The two rates needed to price one model, in USD per 1,000,000 tokens.
332
+ *
333
+ * List prices only — this package models no cached-input discount, no batch tier and no negotiated
334
+ * rate, so a cost computed here is an upper bound for anyone on a discount and simply wrong for a
335
+ * provider that bills per request.
336
+ *
337
+ * @public
338
+ */
153
339
  interface ModelPricing {
154
340
  /** USD per 1,000,000 input tokens. */
155
341
  readonly inputPerMillionUsd: number;
156
342
  /** USD per 1,000,000 output tokens. */
157
343
  readonly outputPerMillionUsd: number;
158
344
  }
159
- /** Built-in pricing table — exhaustive enough for v0.1; users supply overrides for niche models. */
345
+ /**
346
+ * The nine models this package prices out of the box, keyed by the exact `model` string a
347
+ * `BudgetUsageEvent` carries: four OpenAI, three Anthropic, two Google.
348
+ *
349
+ * Lookup is exact-match. No prefix stripping, no aliasing, no fuzzy fallback — `"gpt-4o"` and
350
+ * `"openrouter/openai/gpt-4o"` both MISS the `"openai/gpt-4o"` entry, and a miss means unknown
351
+ * cost, which under a `maxUsd` cap denies the run. Pass overrides through
352
+ * `createUsdBudgetTracker({ pricing })` rather than expecting a match.
353
+ *
354
+ * Prices are a dated snapshot (verified 2026-06) of public list prices and DRIFT — treat a total
355
+ * derived from them as an estimate, not as a bill. `Object.freeze` here is shallow: the map cannot
356
+ * gain keys, but the `ModelPricing` objects inside it are mutable.
357
+ *
358
+ * @public
359
+ */
160
360
  declare const BUILTIN_PRICING: Readonly<Record<string, ModelPricing>>;
161
361
  /**
162
362
  * Compute USD cost for a single token event.
@@ -197,19 +397,71 @@ declare function computeUsdCost(pricing: Readonly<Record<string, ModelPricing>>,
197
397
  * @public
198
398
  */
199
399
 
200
- /** Options for `createUsdBudgetTracker`. */
400
+ /**
401
+ * Options for {@link createUsdBudgetTracker}. Omitting all of them builds a tracker that counts and
402
+ * never denies.
403
+ *
404
+ * @public
405
+ */
201
406
  interface UsdBudgetTrackerOptions {
202
- /** Hard ceiling on total tokens (input + output combined). */
407
+ /**
408
+ * Ceiling on total tokens, input and output combined. `check()` denies with
409
+ * `reason: "token_limit"` once the running total REACHES it (`>=`, not `>`).
410
+ */
203
411
  readonly maxTokens?: number;
204
- /** Hard ceiling on cumulative USD spend. */
412
+ /**
413
+ * Ceiling on cumulative USD. `check()` denies with `reason: "cost_limit"` once the total reaches
414
+ * it.
415
+ *
416
+ * TRAP — setting this makes an UNPRICED MODEL DENY EVERYTHING. If any tracked event names a model
417
+ * absent from the pricing table, the total cost becomes permanently unknown, and a cap that
418
+ * cannot be verified fails CLOSED: every subsequent `check()` returns
419
+ * `{ allowed: false, reason: "cost_limit", detail: "cost unknown — …" }`. This is intentional
420
+ * (better than spending unbounded), but it means a model id that merely differs in spelling from
421
+ * a {@link BUILTIN_PRICING} key halts the agent. Supply `pricing` for anything not in that table,
422
+ * or leave `maxUsd` unset and gate on `maxTokens`.
423
+ */
205
424
  readonly maxUsd?: number;
206
- /** Optional override map — model id → pricing entry. Merged onto BUILTIN_PRICING. */
425
+ /**
426
+ * Per-model prices, spread OVER {@link BUILTIN_PRICING} — so an entry here replaces a built-in of
427
+ * the same key, and built-ins you do not name are kept.
428
+ *
429
+ * Keys are matched EXACTLY against `BudgetUsageEvent.model`. There is no prefix, alias or
430
+ * provider-stripping logic: `"gpt-4o"` does not match the built-in `"openai/gpt-4o"`. Use the
431
+ * same id string you pass to `Agent.create({ model })`.
432
+ */
207
433
  readonly pricing?: Readonly<Record<string, ModelPricing>>;
208
434
  }
209
435
  /**
210
- * Build a fresh USD-aware tracker. The returned object exposes the
211
- * `BudgetTracker` contract PLUS `getTotalUsd()` + `nextIteration()`
212
- * helpers for explicit iteration counting.
436
+ * Build a `BudgetTracker` that caps an agent run on tokens, on USD, or on both.
437
+ *
438
+ * ```ts
439
+ * const agent = await Agent.create({
440
+ * model: { id: "openai/gpt-4o-mini" },
441
+ * budgetTracker: createUsdBudgetTracker({ maxUsd: 0.5 }),
442
+ * });
443
+ * ```
444
+ *
445
+ * The agent loop drives it: `track()` on every usage event, `nextIteration()` once per turn, and
446
+ * `check()` before each turn — a denial halts the loop. Everything is per-instance and in-memory,
447
+ * so one tracker is one run; reuse it across runs and the caps carry over.
448
+ *
449
+ * Three things a caller gets wrong here:
450
+ *
451
+ * - **`getTotal()` has no cost in it.** It returns `{ tokens, iterations }` only, and
452
+ * `BudgetTotal.costUsd` is always `undefined` however much was spent. USD comes from the extra
453
+ * `getTotalUsd()` on the returned object, which is `number | undefined` — `undefined` meaning
454
+ * UNKNOWN, never zero.
455
+ * - **Unknown cost is a one-way door.** The first event naming a model outside the pricing table
456
+ * makes `getTotalUsd()` `undefined` for the rest of the run; a later priced event does not
457
+ * restore it. Combined with `maxUsd`, that also denies every later `check()` — see
458
+ * {@link UsdBudgetTrackerOptions.maxUsd}.
459
+ * - **There is no iteration cap.** `iterations` is counted and reported, and nothing ever gates on
460
+ * it. Use `createCounterBudgetTracker` from `@theokit/sdk` when you need `maxIterations`.
461
+ *
462
+ * `check()` evaluates the cost cap first, so a run breaching both caps reports `"cost_limit"`.
463
+ * `track()` never throws and never rejects input — a non-finite or non-positive `tokens` is
464
+ * discarded, silently.
213
465
  */
214
466
  declare function createUsdBudgetTracker(options?: UsdBudgetTrackerOptions): BudgetTracker & {
215
467
  nextIteration(): void;