llmbrake 0.1.0 → 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/README.md CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  **Per-customer cost caps for AI apps: put a monthly dollar budget on every customer, switch them to a cheaper model near the cap, and block them at the cap.**
4
4
 
5
+ [![npm version](https://img.shields.io/npm/v/llmbrake.svg)](https://www.npmjs.com/package/llmbrake)
5
6
  [![CI](https://github.com/llmbrake/llmbrake/actions/workflows/ci.yml/badge.svg)](https://github.com/llmbrake/llmbrake/actions/workflows/ci.yml)
6
7
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
8
 
@@ -19,6 +20,8 @@ Status: `0.1.0`, early. Website: [llmbrake.com](https://llmbrake.com)
19
20
  npm install llmbrake
20
21
  ```
21
22
 
23
+ Published on npm as [`llmbrake`](https://www.npmjs.com/package/llmbrake). To try the latest `main` instead, run `npm install github:llmbrake/llmbrake` (it builds on install).
24
+
22
25
  `llmbrake` is ESM-only (`import`, not `require`).
23
26
 
24
27
  ## 30-second quickstart
@@ -85,6 +88,16 @@ With `stream: true`, `llmbrake` sets `stream_options.include_usage = true` and r
85
88
 
86
89
  Any `create(params)` function that accepts `{ model, ... }` and returns `{ model, usage: { prompt_tokens, completion_tokens } }` works, for example OpenRouter, Anthropic's OpenAI-compatible endpoint and local gateways. Add their model IDs to the price table.
87
90
 
91
+ ## Examples: Vercel AI SDK, OpenRouter, LangChain
92
+
93
+ Tested integrations live in [`examples/`](examples/):
94
+
95
+ - **[Vercel AI SDK](examples/vercel-ai-sdk/)**: `guard.check()` before `generateText` / `streamText`, then charge from `result.usage` (cached tokens included). Also has a Next.js route handler sketch.
96
+ - **[OpenRouter](examples/openrouter/)**: `guard.wrap()` on the OpenAI SDK pointed at OpenRouter, with OpenRouter model IDs in the price table.
97
+ - **[LangChain.js](examples/langchain/)**: `guard.check()` before `invoke()`, then charge from `AIMessage.usage_metadata`.
98
+
99
+ CI runs them against mock providers (no API keys).
100
+
88
101
  ## Storage (Redis example)
89
102
 
90
103
  The default `MemoryStore` keeps totals in process memory: fine for one server, tests and demos, but lost on restart and not shared between instances. For multiple instances or serverless, implement the two-method `UsageStore` interface. With [ioredis](https://github.com/redis/ioredis):
@@ -118,6 +131,24 @@ const guard = createGuard({ budgetUsd: 5, store: redisStore });
118
131
 
119
132
  `INCRBYFLOAT` is atomic, so totals stay correct across instances. If the store throws (or returns a non-numeric value), calls **fail open** by default so your product keeps working; set `failOpen: false` to fail closed instead.
120
133
 
134
+ ## Shared budgets across servers (`HostedStore`)
135
+
136
+ `HostedStore` keeps the counters in LLMBrake Cloud, so every server and serverless instance using your project's API key shares one counter per customer, and budgets you edit in the dashboard apply on the next call, with no Redis to run:
137
+
138
+ ```ts
139
+ import { createGuard, HostedStore } from "llmbrake";
140
+
141
+ const guard = createGuard({
142
+ budgetUsd: 5, // default; a budget set in the dashboard for a customer wins
143
+ downgradeMap: { "gpt-5.5": "gpt-5.4-mini" },
144
+ store: new HostedStore({ apiKey: process.env.LLMBRAKE_API_KEY! }),
145
+ });
146
+ ```
147
+
148
+ It also closes the concurrent-overshoot gap: before each call the guard **reserves** an estimate of the call's cost (prompt size plus `max_tokens`, or `estimateUsd`), and the reservation only succeeds if spend plus other in-flight reservations is under the budget, in one atomic step on the server. After the call it **settles** the real cost and releases the hold (failed provider calls release it at $0). Parallel calls can then overshoot by at most about one call, instead of by everything in flight. If the API is unreachable the guard follows `failOpen` (default: let the call through).
149
+
150
+ Any store can opt in to this by implementing `reserve` and `settle` (the `ReservingStore` interface).
151
+
121
152
  ## Options
122
153
 
123
154
  `createGuard(options)` returns `{ wrap, check, status }`.
@@ -133,6 +164,7 @@ const guard = createGuard({ budgetUsd: 5, store: redisStore });
133
164
  | `unknownModel` | `"throw"` | Model missing from `prices`: `"throw"` an `UnknownModelPriceError` before the call, or `"allow"` it and record `costUsd: null`. |
134
165
  | `failOpen` | `true` | If the store errors: `true` lets calls through, `false` rethrows. |
135
166
  | `now` | `() => new Date()` | Clock override, mainly for tests. |
167
+ | `estimateUsd` | estimated | Reserving stores only (`HostedStore`): USD to hold while a call is in flight, a number or `(ctx, params) => number`. Default: prompt length / 4 tokens plus `max_completion_tokens`/`max_tokens` (or 1,000) at the requested model's price. |
136
168
 
137
169
  The call context passed as the second argument to the wrapped function is `{ customerId: string; plan?: string; feature?: string }`.
138
170
 
@@ -158,15 +190,27 @@ Dated snapshot IDs such as `gpt-5.4-mini-2026-03-17` fall back to the undated en
158
190
 
159
191
  ## Known limitations
160
192
 
161
- - **Concurrent overshoot.** The budget is checked before a call and the cost is recorded after it. Several in-flight calls for the same customer can each pass the check, so the cap can be overshot by roughly the cost of those concurrent calls. Keep a margin in your budgets if this matters.
193
+ - **Concurrent overshoot.** With `MemoryStore` or a plain `UsageStore`, the budget is checked before a call and the cost is recorded after it, so several in-flight calls for the same customer can each pass the check and overshoot the cap by roughly their combined cost. `HostedStore` (or any `ReservingStore`) reserves before each call, which bounds the overshoot to about one call.
162
194
  - **Prices need verification.** See above. Long-context tiers, batch discounts, regional or data-residency uplifts and priority tiers are not modeled, and the Anthropic IDs in the default table may differ from what your endpoint accepts.
163
195
  - **Chat completions only.** Only chat-completions-style token usage is metered: no images, audio, embeddings or tool fees yet.
164
196
  - **Streaming needs usage.** If a provider ignores `include_usage`, or a stream is abandoned before the usage chunk arrives, that call is recorded with zero cost.
165
197
  - **UTC calendar months.** Budget periods reset at 00:00 UTC on the 1st.
166
198
 
167
- ## Hosted dashboard (coming)
199
+ ## Python
200
+
201
+ A Python port with the same behavior lives in [`python/`](python/README.md): sync and async `openai` clients, streaming, pluggable stores (in-memory, Redis), zero required dependencies, Python 3.9+.
202
+
203
+ `pip install llmbrake` is coming soon to PyPI. For now, install from GitHub:
204
+
205
+ ```bash
206
+ pip install "git+https://github.com/llmbrake/llmbrake#subdirectory=python"
207
+ ```
208
+
209
+ See [python/README.md](python/README.md) for the quickstart.
210
+
211
+ ## Hosted dashboard (LLMBrake Cloud)
168
212
 
169
- The library is free and stays MIT licensed. We're building an optional hosted dashboard on top of it: cost per customer, feature and model, shared counters, alerts near and at caps, and AI cost vs. Stripe revenue per customer. It is **not available yet**; join the waitlist at [llmbrake.com](https://llmbrake.com).
213
+ The library is free and stays MIT licensed. LLMBrake Cloud is the optional paid layer on top: shared counters (`HostedStore`), a dashboard with spend, budget and status per customer (edit budgets live), Slack/webhook alerts at 80% and 100%, and AI cost vs. Stripe revenue per customer. See [llmbrake.com](https://llmbrake.com).
170
214
 
171
215
  ## Contributing
172
216
 
package/dist/guard.d.ts CHANGED
@@ -41,7 +41,15 @@ export interface GuardOptions {
41
41
  failOpen?: boolean;
42
42
  /** Clock override for tests. */
43
43
  now?: () => Date;
44
+ /**
45
+ * Only used with a reserving store (e.g. HostedStore): the cost to hold while a call is in flight.
46
+ * Default: estimated from the prompt size (~4 chars/token) plus `max_completion_tokens`/`max_tokens`
47
+ * (or 1,000 output tokens) at the requested model's price.
48
+ */
49
+ estimateUsd?: number | ((ctx: CallContext, params: Record<string, unknown>) => number);
44
50
  }
51
+ /** Rough upper-ish estimate of a call's cost, used only to size reservations. */
52
+ export declare function estimateCallUsd(model: string, params: Record<string, unknown>, prices?: PriceTable): number;
45
53
  type ChatParams = {
46
54
  model: string;
47
55
  stream?: boolean | null;
@@ -58,6 +66,7 @@ export declare function createGuard(opts: GuardOptions): {
58
66
  spent: number;
59
67
  model: string;
60
68
  downgraded: boolean;
69
+ holdId: string | null;
61
70
  }>;
62
71
  status: (ctx: CallContext) => Promise<{
63
72
  period: string;
package/dist/guard.js CHANGED
@@ -1,6 +1,17 @@
1
1
  import { BudgetExceededError, UnknownModelPriceError } from "./errors.js";
2
2
  import { DEFAULT_PRICES, computeCostUsd } from "./prices.js";
3
- import { MemoryStore } from "./store.js";
3
+ import { MemoryStore, isReservingStore } from "./store.js";
4
+ /** Rough upper-ish estimate of a call's cost, used only to size reservations. */
5
+ export function estimateCallUsd(model, params, prices = DEFAULT_PRICES) {
6
+ let chars = 0;
7
+ try {
8
+ chars = JSON.stringify(params.messages ?? params.input ?? params.prompt ?? "").length;
9
+ }
10
+ catch { /* circular: ignore */ }
11
+ const maxOut = Number(params.max_completion_tokens ?? params.max_tokens ?? 1000);
12
+ const cost = computeCostUsd(model, { prompt_tokens: Math.ceil(chars / 4), completion_tokens: Number.isFinite(maxOut) && maxOut > 0 ? maxOut : 1000 }, prices);
13
+ return cost ?? 0;
14
+ }
4
15
  export function periodOf(d) {
5
16
  return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
6
17
  }
@@ -49,7 +60,63 @@ export function createGuard(opts) {
49
60
  effective = downgradeMap[model];
50
61
  if (unknownModel === "throw" && computeCostUsd(effective, {}, prices) === null)
51
62
  throw new UnknownModelPriceError(effective);
52
- return { period, budget, spent, model: effective, downgraded: effective !== model };
63
+ return { period, budget, spent, model: effective, downgraded: effective !== model, holdId: null };
64
+ }
65
+ function decide(ctx, model, period, budget, spent) {
66
+ if (spent >= budget)
67
+ throw new BudgetExceededError(ctx.customerId, spent, budget, period);
68
+ let effective = model;
69
+ if (budget !== Infinity && spent >= budget * downgradeAt && downgradeMap[model])
70
+ effective = downgradeMap[model];
71
+ if (unknownModel === "throw" && computeCostUsd(effective, {}, prices) === null)
72
+ throw new UnknownModelPriceError(effective);
73
+ return { period, budget, spent, model: effective, downgraded: effective !== model, holdId: null };
74
+ }
75
+ /** Reserving stores: atomic check-and-reserve before the call (see ReservingStore). */
76
+ async function reserveCheck(ctx, params) {
77
+ const period = periodOf(now());
78
+ const budget = await budgetFor(ctx);
79
+ if (!isReservingStore(store))
80
+ throw new Error("unreachable");
81
+ let holdUsd = 0;
82
+ try {
83
+ holdUsd = typeof opts.estimateUsd === "function" ? opts.estimateUsd(ctx, params) : opts.estimateUsd ?? estimateCallUsd(params.model, params, prices);
84
+ if (!Number.isFinite(holdUsd) || holdUsd < 0)
85
+ holdUsd = 0;
86
+ }
87
+ catch {
88
+ holdUsd = 0;
89
+ }
90
+ let r;
91
+ try {
92
+ r = await store.reserve({ customerId: ctx.customerId, period, holdUsd, budgetUsd: budget, downgradeAt, stream: !!params.stream });
93
+ if (typeof r?.spentUsd !== "number" || !Number.isFinite(r.spentUsd))
94
+ throw new TypeError("llmbrake: store.reserve returned a non-numeric spentUsd");
95
+ }
96
+ catch (e) {
97
+ if (!failOpen)
98
+ throw e;
99
+ return decide(ctx, params.model, period, budget, 0);
100
+ }
101
+ const effBudget = typeof r.budgetUsd === "number" && !Number.isNaN(r.budgetUsd) ? r.budgetUsd : budget;
102
+ if (!r.allowed)
103
+ throw new BudgetExceededError(ctx.customerId, r.spentUsd, effBudget, period);
104
+ // The server already admitted the call; only the downgrade and unknown-model rules apply here.
105
+ const model = params.model;
106
+ const effective = effBudget !== Infinity && r.spentUsd >= effBudget * downgradeAt && downgradeMap[model] ? downgradeMap[model] : model;
107
+ if (unknownModel === "throw" && computeCostUsd(effective, {}, prices) === null) {
108
+ await release(ctx, period, r.holdId);
109
+ throw new UnknownModelPriceError(effective);
110
+ }
111
+ return { period, budget: effBudget, spent: r.spentUsd, model: effective, downgraded: effective !== model, holdId: r.holdId };
112
+ }
113
+ async function release(ctx, period, holdId) {
114
+ if (!holdId || !isReservingStore(store))
115
+ return;
116
+ try {
117
+ await store.settle({ holdId, customerId: ctx.customerId, period, costUsd: 0, downgraded: false });
118
+ }
119
+ catch { /* hold expires on its own */ }
53
120
  }
54
121
  async function record(ctx, d, requestedModel, respModel, usage) {
55
122
  const u = usage ?? {};
@@ -57,7 +124,16 @@ export function createGuard(opts) {
57
124
  const priceModel = respModel && computeCostUsd(respModel, {}, prices) !== null ? respModel : d.model;
58
125
  const cost = computeCostUsd(priceModel, u, prices);
59
126
  let spent = d.spent;
60
- if (cost !== null && cost > 0) {
127
+ if (d.holdId && isReservingStore(store)) {
128
+ try {
129
+ spent = await store.settle({ holdId: d.holdId, customerId: ctx.customerId, period: d.period, costUsd: cost ?? 0, downgraded: d.downgraded });
130
+ }
131
+ catch (e) {
132
+ if (!failOpen)
133
+ throw e;
134
+ }
135
+ }
136
+ else if (cost !== null && cost > 0) {
61
137
  try {
62
138
  spent = await store.addSpend(ctx.customerId, d.period, cost);
63
139
  }
@@ -91,14 +167,28 @@ export function createGuard(opts) {
91
167
  if (!ctx?.customerId)
92
168
  throw new TypeError("llmbrake: ctx.customerId is required");
93
169
  const requestedModel = params.model;
94
- const d = await check(ctx, requestedModel);
170
+ const d = isReservingStore(store) ? await reserveCheck(ctx, params) : await check(ctx, requestedModel);
95
171
  const finalParams = { ...params, model: d.model };
96
172
  if (params.stream) {
97
173
  finalParams.stream_options = { ...(params.stream_options ?? {}), include_usage: true };
98
- const stream = (await createFn(finalParams, ...rest));
174
+ let stream;
175
+ try {
176
+ stream = (await createFn(finalParams, ...rest));
177
+ }
178
+ catch (e) {
179
+ await release(ctx, d.period, d.holdId);
180
+ throw e;
181
+ }
99
182
  return wrapStream(stream, (model, usage) => record(ctx, d, requestedModel, model, usage));
100
183
  }
101
- const res = await createFn(finalParams, ...rest);
184
+ let res;
185
+ try {
186
+ res = await createFn(finalParams, ...rest);
187
+ }
188
+ catch (e) {
189
+ await release(ctx, d.period, d.holdId);
190
+ throw e;
191
+ }
102
192
  await record(ctx, d, requestedModel, res?.model, res?.usage);
103
193
  return res;
104
194
  };
@@ -0,0 +1,42 @@
1
+ import type { Reservation, ReserveRequest, ReservingStore, SettleRequest } from "./store.js";
2
+ export declare const DEFAULT_HOSTED_URL = "https://app.llmbrake.com";
3
+ export interface HostedStoreOptions {
4
+ /** Your project's SDK API key (lbsk_...). */
5
+ apiKey: string;
6
+ /** API base URL. Default https://app.llmbrake.com */
7
+ baseUrl?: string;
8
+ /** Per-request timeout in ms. Default 3000. On timeout the guard follows `failOpen`. */
9
+ timeoutMs?: number;
10
+ /** fetch implementation (defaults to global fetch; Node 18+). */
11
+ fetch?: typeof fetch;
12
+ }
13
+ export declare class HostedStoreError extends Error {
14
+ readonly status?: number | undefined;
15
+ constructor(message: string, status?: number | undefined);
16
+ }
17
+ /**
18
+ * Shared, hosted spend counters (LLMBrake Cloud). One counter per customer per month across every
19
+ * server using the same API key; budgets edited in the dashboard override the guard's `budgetUsd`.
20
+ *
21
+ * const guard = createGuard({ budgetUsd: 5, store: new HostedStore({ apiKey: process.env.LLMBRAKE_API_KEY! }) });
22
+ */
23
+ export declare class HostedStore implements ReservingStore {
24
+ private readonly base;
25
+ private readonly key;
26
+ private readonly timeoutMs;
27
+ private readonly f;
28
+ constructor(opts: HostedStoreOptions);
29
+ private call;
30
+ getSpend(customerId: string, period: string): Promise<number>;
31
+ addSpend(customerId: string, period: string, usd: number): Promise<number>;
32
+ reserve(req: ReserveRequest): Promise<Reservation>;
33
+ settle(req: SettleRequest): Promise<number>;
34
+ /** Server view of a customer this period (spend, in-flight holds, effective budget, status). */
35
+ customer(customerId: string, period: string): Promise<{
36
+ spentUsd: number;
37
+ pendingUsd: number;
38
+ budgetUsd: number | null;
39
+ budgetSource: string;
40
+ status: string;
41
+ }>;
42
+ }
package/dist/hosted.js ADDED
@@ -0,0 +1,79 @@
1
+ export const DEFAULT_HOSTED_URL = "https://app.llmbrake.com";
2
+ export class HostedStoreError extends Error {
3
+ status;
4
+ constructor(message, status) {
5
+ super(message);
6
+ this.status = status;
7
+ this.name = "HostedStoreError";
8
+ }
9
+ }
10
+ /**
11
+ * Shared, hosted spend counters (LLMBrake Cloud). One counter per customer per month across every
12
+ * server using the same API key; budgets edited in the dashboard override the guard's `budgetUsd`.
13
+ *
14
+ * const guard = createGuard({ budgetUsd: 5, store: new HostedStore({ apiKey: process.env.LLMBRAKE_API_KEY! }) });
15
+ */
16
+ export class HostedStore {
17
+ base;
18
+ key;
19
+ timeoutMs;
20
+ f;
21
+ constructor(opts) {
22
+ if (!opts?.apiKey)
23
+ throw new TypeError("llmbrake: HostedStore needs an apiKey");
24
+ this.key = opts.apiKey;
25
+ this.base = (opts.baseUrl ?? DEFAULT_HOSTED_URL).replace(/\/+$/, "");
26
+ this.timeoutMs = opts.timeoutMs ?? 3000;
27
+ const f = opts.fetch ?? globalThis.fetch;
28
+ if (!f)
29
+ throw new TypeError("llmbrake: HostedStore needs fetch (Node 18+), or pass opts.fetch");
30
+ this.f = f;
31
+ }
32
+ async call(method, path, body) {
33
+ const ctl = new AbortController();
34
+ const t = setTimeout(() => ctl.abort(), this.timeoutMs);
35
+ try {
36
+ const r = await this.f(this.base + path, {
37
+ method,
38
+ headers: { authorization: `Bearer ${this.key}`, "content-type": "application/json", "user-agent": "llmbrake-js" },
39
+ body: body === undefined ? undefined : JSON.stringify(body),
40
+ signal: ctl.signal,
41
+ });
42
+ const data = await r.json().catch(() => ({}));
43
+ if (!r.ok)
44
+ throw new HostedStoreError(`llmbrake: hosted API ${r.status} ${data?.error ?? ""}`.trim(), r.status);
45
+ return data;
46
+ }
47
+ catch (e) {
48
+ if (e instanceof HostedStoreError)
49
+ throw e;
50
+ throw new HostedStoreError(`llmbrake: hosted API unreachable (${e?.name === "AbortError" ? `timeout after ${this.timeoutMs}ms` : String(e?.message ?? e)})`);
51
+ }
52
+ finally {
53
+ clearTimeout(t);
54
+ }
55
+ }
56
+ async getSpend(customerId, period) {
57
+ const q = new URLSearchParams({ customerId, period });
58
+ return (await this.call("GET", `/v1/spend?${q}`)).spentUsd;
59
+ }
60
+ async addSpend(customerId, period, usd) {
61
+ return (await this.call("POST", "/v1/spend/add", { customerId, period, usd })).spentUsd;
62
+ }
63
+ async reserve(req) {
64
+ const r = await this.call("POST", "/v1/reserve", {
65
+ customerId: req.customerId, period: req.period, holdUsd: req.holdUsd,
66
+ budgetUsd: Number.isFinite(req.budgetUsd) ? req.budgetUsd : null,
67
+ downgradeAt: req.downgradeAt, ttlSec: req.stream ? 900 : 120,
68
+ });
69
+ return { allowed: !!r.allowed, spentUsd: r.spentUsd, budgetUsd: r.budgetUsd === null ? Infinity : r.budgetUsd, holdId: r.holdId ?? null };
70
+ }
71
+ async settle(req) {
72
+ return (await this.call("POST", "/v1/settle", req)).spentUsd;
73
+ }
74
+ /** Server view of a customer this period (spend, in-flight holds, effective budget, status). */
75
+ async customer(customerId, period) {
76
+ const q = new URLSearchParams({ customerId, period });
77
+ return this.call("GET", `/v1/spend?${q}`);
78
+ }
79
+ }
package/dist/index.d.ts CHANGED
@@ -1,7 +1,9 @@
1
- export { createGuard, periodOf } from "./guard.js";
1
+ export { createGuard, periodOf, estimateCallUsd } from "./guard.js";
2
2
  export type { GuardOptions, CallContext, UsageEvent } from "./guard.js";
3
- export { MemoryStore } from "./store.js";
4
- export type { UsageStore } from "./store.js";
3
+ export { MemoryStore, isReservingStore } from "./store.js";
4
+ export type { UsageStore, ReservingStore, Reservation, ReserveRequest, SettleRequest } from "./store.js";
5
+ export { HostedStore, HostedStoreError, DEFAULT_HOSTED_URL } from "./hosted.js";
6
+ export type { HostedStoreOptions } from "./hosted.js";
5
7
  export { DEFAULT_PRICES, computeCostUsd, lookupPrice } from "./prices.js";
6
8
  export type { ModelPrice, PriceTable, ChatUsage } from "./prices.js";
7
9
  export { BudgetExceededError, UnknownModelPriceError, isBudgetExceededError } from "./errors.js";
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
- export { createGuard, periodOf } from "./guard.js";
2
- export { MemoryStore } from "./store.js";
1
+ export { createGuard, periodOf, estimateCallUsd } from "./guard.js";
2
+ export { MemoryStore, isReservingStore } from "./store.js";
3
+ export { HostedStore, HostedStoreError, DEFAULT_HOSTED_URL } from "./hosted.js";
3
4
  export { DEFAULT_PRICES, computeCostUsd, lookupPrice } from "./prices.js";
4
5
  export { BudgetExceededError, UnknownModelPriceError, isBudgetExceededError } from "./errors.js";
package/dist/store.d.ts CHANGED
@@ -5,6 +5,46 @@ export interface UsageStore {
5
5
  /** Atomically add USD and return the new total. */
6
6
  addSpend(customerId: string, period: string, usd: number): Promise<number>;
7
7
  }
8
+ /** What a {@link ReservingStore} answers before a call. */
9
+ export interface Reservation {
10
+ /** false = the customer is at/over budget (counting other in-flight calls); the call must not be made. */
11
+ allowed: boolean;
12
+ /** Settled spend this period (USD). */
13
+ spentUsd: number;
14
+ /** Effective budget (USD). Infinity = unlimited. May differ from the one you sent if a dashboard override is set. */
15
+ budgetUsd: number;
16
+ /** Pass back to `settle`. null when not allowed (or when the store failed open). */
17
+ holdId: string | null;
18
+ }
19
+ export interface ReserveRequest {
20
+ customerId: string;
21
+ period: string;
22
+ /** Estimated cost of the call, held until it settles. */
23
+ holdUsd: number;
24
+ /** Budget from your guard config. Infinity = unlimited. */
25
+ budgetUsd: number;
26
+ downgradeAt: number;
27
+ /** Streams get a longer hold before it expires on its own. */
28
+ stream: boolean;
29
+ }
30
+ export interface SettleRequest {
31
+ holdId: string | null;
32
+ customerId: string;
33
+ period: string;
34
+ /** Actual cost; 0 releases the hold without charging (e.g. the provider call failed). */
35
+ costUsd: number;
36
+ downgraded: boolean;
37
+ }
38
+ /**
39
+ * Optional extension of {@link UsageStore} for shared stores (e.g. {@link HostedStore}).
40
+ * When the guard's store implements it, wrapped calls *check-and-reserve* atomically before the
41
+ * provider call and *settle* after it, so parallel calls across servers can't overshoot the cap together.
42
+ */
43
+ export interface ReservingStore extends UsageStore {
44
+ reserve(req: ReserveRequest): Promise<Reservation>;
45
+ settle(req: SettleRequest): Promise<number>;
46
+ }
47
+ export declare function isReservingStore(s: UsageStore): s is ReservingStore;
8
48
  /** In-memory store. Fine for a single process, tests and demos; state is lost on restart. */
9
49
  export declare class MemoryStore implements UsageStore {
10
50
  private data;
package/dist/store.js CHANGED
@@ -1,3 +1,6 @@
1
+ export function isReservingStore(s) {
2
+ return typeof s.reserve === "function" && typeof s.settle === "function";
3
+ }
1
4
  /** In-memory store. Fine for a single process, tests and demos; state is lost on restart. */
2
5
  export class MemoryStore {
3
6
  data = new Map();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llmbrake",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Per-customer monthly USD budgets for OpenAI-compatible chat calls: track cost, auto-downgrade near the cap, block at the cap.",
5
5
  "license": "MIT",
6
6
  "author": "LLMBrake contributors",