llmbrake 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +220 -2
- package/dist/errors.d.ts +14 -0
- package/dist/errors.js +27 -0
- package/dist/guard.d.ts +78 -0
- package/dist/guard.js +226 -0
- package/dist/hosted.d.ts +42 -0
- package/dist/hosted.js +79 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +5 -0
- package/dist/prices.d.ts +35 -0
- package/dist/prices.js +34 -0
- package/dist/store.d.ts +62 -0
- package/dist/store.js +25 -0
- package/package.json +56 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 LLMBrake contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,221 @@
|
|
|
1
|
-
#
|
|
1
|
+
# llmbrake
|
|
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
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/llmbrake)
|
|
6
|
+
[](https://github.com/llmbrake/llmbrake/actions/workflows/ci.yml)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
|
|
9
|
+
`llmbrake` is a small TypeScript library that wraps your OpenAI-compatible `chat.completions.create` call. It prices every call in USD from the token usage the provider returns and keeps a running monthly total per customer. Near a customer's budget it swaps in a cheaper model; at the budget it throws a typed `BudgetExceededError` before the request ever reaches the provider.
|
|
10
|
+
|
|
11
|
+
- **Not a proxy.** It runs inside your app and calls your provider directly with your own keys.
|
|
12
|
+
- **Metadata only.** It never reads or stores prompts or completions, only customer ID, plan, feature tag, model, token counts, cost and timestamps.
|
|
13
|
+
- **Zero runtime dependencies.** ESM, Node 18+, MIT licensed.
|
|
14
|
+
|
|
15
|
+
Status: `0.1.0`, early. Website: [llmbrake.com](https://llmbrake.com)
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install llmbrake
|
|
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
|
+
|
|
25
|
+
`llmbrake` is ESM-only (`import`, not `require`).
|
|
26
|
+
|
|
27
|
+
## 30-second quickstart
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import OpenAI from "openai";
|
|
31
|
+
import { createGuard, isBudgetExceededError } from "llmbrake";
|
|
32
|
+
|
|
33
|
+
const openai = new OpenAI();
|
|
34
|
+
|
|
35
|
+
const guard = createGuard({
|
|
36
|
+
budgetUsd: (ctx) => (ctx.plan === "pro" ? 20 : 2), // USD per customer per calendar month (UTC)
|
|
37
|
+
downgradeAt: 0.8, // at 80% of the budget...
|
|
38
|
+
downgradeMap: { "gpt-5.5": "gpt-5.4-mini" }, // ...send gpt-5.5 requests to gpt-5.4-mini
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const chat = openai.chat.completions;
|
|
42
|
+
const create = guard.wrap(chat.create.bind(chat));
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const res = await create(
|
|
46
|
+
{ model: "gpt-5.5", messages: [{ role: "user", content: "Hello" }] },
|
|
47
|
+
{ customerId: user.id, plan: user.plan, feature: "chat" },
|
|
48
|
+
);
|
|
49
|
+
} catch (e) {
|
|
50
|
+
if (isBudgetExceededError(e)) {
|
|
51
|
+
// e.customerId, e.spentUsd, e.budgetUsd, e.period
|
|
52
|
+
return showUpgradePrompt();
|
|
53
|
+
}
|
|
54
|
+
throw e;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
await guard.status({ customerId: user.id, plan: user.plan });
|
|
58
|
+
// → { period: "2026-10", spentUsd: …, budgetUsd: 20, remainingUsd: … }
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## How it works
|
|
62
|
+
|
|
63
|
+
Every wrapped call goes through three steps:
|
|
64
|
+
|
|
65
|
+
1. **Check (before the call).** `llmbrake` resolves the customer's budget (a number, or your `(ctx) => number` function, e.g. by plan) and reads their spend for the current period, a calendar month in UTC such as `2026-10`.
|
|
66
|
+
2. **Decide.**
|
|
67
|
+
- **Block:** if `spent >= budget`, it throws `BudgetExceededError` (`code: "LLMBRAKE_BUDGET_EXCEEDED"`) and **the provider is never called**. Catch it to show an upgrade prompt.
|
|
68
|
+
- **Downgrade:** if `spent >= budget × downgradeAt` and the requested model has an entry in `downgradeMap`, the request is sent with the cheaper model instead. The usage event reports `downgraded: true` and the original `requestedModel`.
|
|
69
|
+
- Otherwise the request goes through unchanged.
|
|
70
|
+
3. **Record (after the call).** The cost is computed from the response's `usage` (uncached input, cached input and output tokens) and your price table, then added to the customer's total. The `onUsage` hook receives a usage event:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
{
|
|
74
|
+
customerId: "cus_123", plan: "pro", feature: "chat", period: "2026-10",
|
|
75
|
+
requestedModel: "gpt-5.5", model: "gpt-5.4-mini", downgraded: true,
|
|
76
|
+
promptTokens: 1102, completionTokens: 182, cachedTokens: 640,
|
|
77
|
+
costUsd: 0.0012, spentUsd: 16.41, budgetUsd: 20, timestamp: "2026-10-11T20:00:00.000Z"
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`guard.check(ctx, model)` runs steps 1–2 without making a call, which is handy for disabling a button before the user clicks it.
|
|
82
|
+
|
|
83
|
+
### Streaming
|
|
84
|
+
|
|
85
|
+
With `stream: true`, `llmbrake` sets `stream_options.include_usage = true` and records the cost when the stream ends, including when your code stops reading early (whatever usage was received by then is recorded).
|
|
86
|
+
|
|
87
|
+
### Other OpenAI-compatible endpoints
|
|
88
|
+
|
|
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.
|
|
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
|
+
|
|
101
|
+
## Storage (Redis example)
|
|
102
|
+
|
|
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):
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
import Redis from "ioredis";
|
|
107
|
+
import { createGuard, type UsageStore } from "llmbrake";
|
|
108
|
+
|
|
109
|
+
const redis = new Redis(process.env.REDIS_URL!);
|
|
110
|
+
const key = (customerId: string, period: string) => `llmbrake:${period}:${customerId}`;
|
|
111
|
+
|
|
112
|
+
const redisStore: UsageStore = {
|
|
113
|
+
async getSpend(customerId, period) {
|
|
114
|
+
return Number((await redis.get(key(customerId, period))) ?? 0);
|
|
115
|
+
},
|
|
116
|
+
async addSpend(customerId, period, usd) {
|
|
117
|
+
const k = key(customerId, period);
|
|
118
|
+
const res = await redis
|
|
119
|
+
.multi()
|
|
120
|
+
.incrbyfloat(k, usd)
|
|
121
|
+
.expire(k, 60 * 60 * 24 * 40) // keep ~40 days, then let old months expire
|
|
122
|
+
.exec();
|
|
123
|
+
const [err, total] = res?.[0] ?? [new Error("Redis transaction failed"), null];
|
|
124
|
+
if (err) throw err;
|
|
125
|
+
return Number(total);
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const guard = createGuard({ budgetUsd: 5, store: redisStore });
|
|
130
|
+
```
|
|
131
|
+
|
|
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.
|
|
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
|
+
|
|
152
|
+
## Options
|
|
153
|
+
|
|
154
|
+
`createGuard(options)` returns `{ wrap, check, status }`.
|
|
155
|
+
|
|
156
|
+
| Option | Default | Description |
|
|
157
|
+
|---|---|---|
|
|
158
|
+
| `budgetUsd` | required | Monthly USD budget: a number, or `(ctx) => number \| Promise<number>`. Must be `>= 0`; use `Infinity` for unlimited. |
|
|
159
|
+
| `store` | `new MemoryStore()` | Where spend is kept. Any `UsageStore` (`getSpend`, `addSpend`). |
|
|
160
|
+
| `prices` | `DEFAULT_PRICES` | Model → `{ inputPerMTok, outputPerMTok, cachedInputPerMTok?, source? }`, USD per 1M tokens. |
|
|
161
|
+
| `downgradeAt` | `0.8` | Fraction of the budget (0–1) at which `downgradeMap` applies. `1` disables downgrading. |
|
|
162
|
+
| `downgradeMap` | `{}` | `{ "expensive-model": "cheaper-model" }`. |
|
|
163
|
+
| `onUsage` | none | `(event) => void \| Promise<void>`, called after each metered call. Errors in it are swallowed. |
|
|
164
|
+
| `unknownModel` | `"throw"` | Model missing from `prices`: `"throw"` an `UnknownModelPriceError` before the call, or `"allow"` it and record `costUsd: null`. |
|
|
165
|
+
| `failOpen` | `true` | If the store errors: `true` lets calls through, `false` rethrows. |
|
|
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. |
|
|
168
|
+
|
|
169
|
+
The call context passed as the second argument to the wrapped function is `{ customerId: string; plan?: string; feature?: string }`.
|
|
170
|
+
|
|
171
|
+
## Model prices: verify before relying on them
|
|
172
|
+
|
|
173
|
+
`DEFAULT_PRICES` covers a few common OpenAI and Anthropic models in USD per 1M tokens (standard tier), read on 2026-10-09 from:
|
|
174
|
+
|
|
175
|
+
- OpenAI: https://developers.openai.com/api/docs/pricing
|
|
176
|
+
- Anthropic: https://docs.anthropic.com/en/docs/about-claude/pricing
|
|
177
|
+
|
|
178
|
+
**Provider prices change often, and these values are not guaranteed current.** Check them against the pages above before using them for billing decisions. Each entry carries a `source` field. Override or extend the table:
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
import { createGuard, DEFAULT_PRICES } from "llmbrake";
|
|
182
|
+
|
|
183
|
+
createGuard({
|
|
184
|
+
budgetUsd: 5,
|
|
185
|
+
prices: { ...DEFAULT_PRICES, "my-model": { inputPerMTok: 1, outputPerMTok: 3 } },
|
|
186
|
+
});
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Dated snapshot IDs such as `gpt-5.4-mini-2026-03-17` fall back to the undated entry.
|
|
190
|
+
|
|
191
|
+
## Known limitations
|
|
192
|
+
|
|
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.
|
|
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.
|
|
195
|
+
- **Chat completions only.** Only chat-completions-style token usage is metered: no images, audio, embeddings or tool fees yet.
|
|
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.
|
|
197
|
+
- **UTC calendar months.** Budget periods reset at 00:00 UTC on the 1st.
|
|
198
|
+
|
|
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)
|
|
212
|
+
|
|
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).
|
|
214
|
+
|
|
215
|
+
## Contributing
|
|
216
|
+
|
|
217
|
+
Issues and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md). Security issues: see [SECURITY.md](SECURITY.md).
|
|
218
|
+
|
|
219
|
+
## License
|
|
220
|
+
|
|
221
|
+
[MIT](LICENSE)
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare class BudgetExceededError extends Error {
|
|
2
|
+
readonly customerId: string;
|
|
3
|
+
readonly spentUsd: number;
|
|
4
|
+
readonly budgetUsd: number;
|
|
5
|
+
readonly period: string;
|
|
6
|
+
readonly code: "LLMBRAKE_BUDGET_EXCEEDED";
|
|
7
|
+
constructor(customerId: string, spentUsd: number, budgetUsd: number, period: string);
|
|
8
|
+
}
|
|
9
|
+
export declare class UnknownModelPriceError extends Error {
|
|
10
|
+
readonly model: string;
|
|
11
|
+
readonly code: "LLMBRAKE_UNKNOWN_MODEL";
|
|
12
|
+
constructor(model: string);
|
|
13
|
+
}
|
|
14
|
+
export declare function isBudgetExceededError(e: unknown): e is BudgetExceededError;
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export class BudgetExceededError extends Error {
|
|
2
|
+
customerId;
|
|
3
|
+
spentUsd;
|
|
4
|
+
budgetUsd;
|
|
5
|
+
period;
|
|
6
|
+
code = "LLMBRAKE_BUDGET_EXCEEDED";
|
|
7
|
+
constructor(customerId, spentUsd, budgetUsd, period) {
|
|
8
|
+
super(`Customer ${customerId} has used $${spentUsd.toFixed(4)} of a $${budgetUsd.toFixed(2)} AI budget for ${period}.`);
|
|
9
|
+
this.customerId = customerId;
|
|
10
|
+
this.spentUsd = spentUsd;
|
|
11
|
+
this.budgetUsd = budgetUsd;
|
|
12
|
+
this.period = period;
|
|
13
|
+
this.name = "BudgetExceededError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export class UnknownModelPriceError extends Error {
|
|
17
|
+
model;
|
|
18
|
+
code = "LLMBRAKE_UNKNOWN_MODEL";
|
|
19
|
+
constructor(model) {
|
|
20
|
+
super(`No price configured for model "${model}". Add it to the price table.`);
|
|
21
|
+
this.model = model;
|
|
22
|
+
this.name = "UnknownModelPriceError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function isBudgetExceededError(e) {
|
|
26
|
+
return e instanceof BudgetExceededError;
|
|
27
|
+
}
|
package/dist/guard.d.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { PriceTable } from "./prices.js";
|
|
2
|
+
import { UsageStore } from "./store.js";
|
|
3
|
+
export interface CallContext {
|
|
4
|
+
customerId: string;
|
|
5
|
+
/** Optional plan name, passed to your budget function. */
|
|
6
|
+
plan?: string;
|
|
7
|
+
/** Optional feature tag for analytics, e.g. "summarize". */
|
|
8
|
+
feature?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface UsageEvent {
|
|
11
|
+
customerId: string;
|
|
12
|
+
plan?: string;
|
|
13
|
+
feature?: string;
|
|
14
|
+
period: string;
|
|
15
|
+
requestedModel: string;
|
|
16
|
+
model: string;
|
|
17
|
+
downgraded: boolean;
|
|
18
|
+
promptTokens: number;
|
|
19
|
+
completionTokens: number;
|
|
20
|
+
cachedTokens: number;
|
|
21
|
+
/** null when the model had no price configured */
|
|
22
|
+
costUsd: number | null;
|
|
23
|
+
spentUsd: number;
|
|
24
|
+
budgetUsd: number;
|
|
25
|
+
timestamp: string;
|
|
26
|
+
}
|
|
27
|
+
export interface GuardOptions {
|
|
28
|
+
/** Monthly USD budget for a customer (number or lookup). Return Infinity for unlimited. */
|
|
29
|
+
budgetUsd: number | ((ctx: CallContext) => number | Promise<number>);
|
|
30
|
+
store?: UsageStore;
|
|
31
|
+
prices?: PriceTable;
|
|
32
|
+
/** Fraction of budget (0–1) at which to switch to a cheaper model. Default 0.8. Set to 1 to disable. */
|
|
33
|
+
downgradeAt?: number;
|
|
34
|
+
/** Map of expensive model -> cheaper model to use once past `downgradeAt`. */
|
|
35
|
+
downgradeMap?: Record<string, string>;
|
|
36
|
+
/** Called after each successful, metered call. Errors thrown here are swallowed. */
|
|
37
|
+
onUsage?: (e: UsageEvent) => void | Promise<void>;
|
|
38
|
+
/** What to do when a model has no price: "throw" before calling, or "allow" and record cost null. Default "throw". */
|
|
39
|
+
unknownModel?: "throw" | "allow";
|
|
40
|
+
/** If the store fails, let the call through (true, default) or rethrow (false). */
|
|
41
|
+
failOpen?: boolean;
|
|
42
|
+
/** Clock override for tests. */
|
|
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);
|
|
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;
|
|
53
|
+
type ChatParams = {
|
|
54
|
+
model: string;
|
|
55
|
+
stream?: boolean | null;
|
|
56
|
+
stream_options?: Record<string, unknown> | null;
|
|
57
|
+
[k: string]: unknown;
|
|
58
|
+
};
|
|
59
|
+
type CreateFn<P, R> = (params: P, ...rest: any[]) => Promise<R>;
|
|
60
|
+
export declare function periodOf(d: Date): string;
|
|
61
|
+
export declare function createGuard(opts: GuardOptions): {
|
|
62
|
+
wrap: <P extends ChatParams, R>(createFn: CreateFn<P, R>) => (params: P, ctx: CallContext, ...rest: any[]) => Promise<R>;
|
|
63
|
+
check: (ctx: CallContext, model: string) => Promise<{
|
|
64
|
+
period: string;
|
|
65
|
+
budget: number;
|
|
66
|
+
spent: number;
|
|
67
|
+
model: string;
|
|
68
|
+
downgraded: boolean;
|
|
69
|
+
holdId: string | null;
|
|
70
|
+
}>;
|
|
71
|
+
status: (ctx: CallContext) => Promise<{
|
|
72
|
+
period: string;
|
|
73
|
+
spentUsd: number;
|
|
74
|
+
budgetUsd: number;
|
|
75
|
+
remainingUsd: number;
|
|
76
|
+
}>;
|
|
77
|
+
};
|
|
78
|
+
export {};
|
package/dist/guard.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { BudgetExceededError, UnknownModelPriceError } from "./errors.js";
|
|
2
|
+
import { DEFAULT_PRICES, computeCostUsd } from "./prices.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
|
+
}
|
|
15
|
+
export function periodOf(d) {
|
|
16
|
+
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
|
|
17
|
+
}
|
|
18
|
+
export function createGuard(opts) {
|
|
19
|
+
const store = opts.store ?? new MemoryStore();
|
|
20
|
+
const prices = opts.prices ?? DEFAULT_PRICES;
|
|
21
|
+
const downgradeAt = opts.downgradeAt ?? 0.8;
|
|
22
|
+
const downgradeMap = opts.downgradeMap ?? {};
|
|
23
|
+
const unknownModel = opts.unknownModel ?? "throw";
|
|
24
|
+
const failOpen = opts.failOpen ?? true;
|
|
25
|
+
const now = opts.now ?? (() => new Date());
|
|
26
|
+
if (typeof downgradeAt !== "number" || !(downgradeAt >= 0 && downgradeAt <= 1)) {
|
|
27
|
+
throw new RangeError(`llmbrake: downgradeAt must be a number between 0 and 1, got ${String(downgradeAt)}`);
|
|
28
|
+
}
|
|
29
|
+
async function budgetFor(ctx) {
|
|
30
|
+
const b = typeof opts.budgetUsd === "function" ? await opts.budgetUsd(ctx) : opts.budgetUsd;
|
|
31
|
+
// NaN or a negative budget would silently disable the cap (spent >= NaN is always false), so reject it.
|
|
32
|
+
if (typeof b !== "number" || Number.isNaN(b) || b < 0) {
|
|
33
|
+
throw new TypeError(`llmbrake: budgetUsd for customer ${ctx.customerId} must be a number >= 0 (or Infinity), got ${String(b)}`);
|
|
34
|
+
}
|
|
35
|
+
return b;
|
|
36
|
+
}
|
|
37
|
+
async function safeGetSpend(c, p) {
|
|
38
|
+
try {
|
|
39
|
+
const spent = await store.getSpend(c, p);
|
|
40
|
+
// A store returning NaN/garbage would also silently disable the cap; treat it as a store failure.
|
|
41
|
+
if (typeof spent !== "number" || !Number.isFinite(spent))
|
|
42
|
+
throw new TypeError(`llmbrake: store.getSpend returned a non-numeric value (${String(spent)})`);
|
|
43
|
+
return spent;
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
if (failOpen)
|
|
47
|
+
return 0;
|
|
48
|
+
throw e;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Decide whether a call may proceed, and with which model. Throws BudgetExceededError at the cap. */
|
|
52
|
+
async function check(ctx, model) {
|
|
53
|
+
const period = periodOf(now());
|
|
54
|
+
const budget = await budgetFor(ctx);
|
|
55
|
+
const spent = await safeGetSpend(ctx.customerId, period);
|
|
56
|
+
if (spent >= budget)
|
|
57
|
+
throw new BudgetExceededError(ctx.customerId, spent, budget, period);
|
|
58
|
+
let effective = model;
|
|
59
|
+
if (budget !== Infinity && spent >= budget * downgradeAt && downgradeMap[model])
|
|
60
|
+
effective = downgradeMap[model];
|
|
61
|
+
if (unknownModel === "throw" && computeCostUsd(effective, {}, prices) === null)
|
|
62
|
+
throw new UnknownModelPriceError(effective);
|
|
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 */ }
|
|
120
|
+
}
|
|
121
|
+
async function record(ctx, d, requestedModel, respModel, usage) {
|
|
122
|
+
const u = usage ?? {};
|
|
123
|
+
// Price by the model the provider reports (dated snapshots resolve via lookupPrice); fall back to the model we sent.
|
|
124
|
+
const priceModel = respModel && computeCostUsd(respModel, {}, prices) !== null ? respModel : d.model;
|
|
125
|
+
const cost = computeCostUsd(priceModel, u, prices);
|
|
126
|
+
let spent = d.spent;
|
|
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) {
|
|
137
|
+
try {
|
|
138
|
+
spent = await store.addSpend(ctx.customerId, d.period, cost);
|
|
139
|
+
}
|
|
140
|
+
catch (e) {
|
|
141
|
+
if (!failOpen)
|
|
142
|
+
throw e;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const event = {
|
|
146
|
+
customerId: ctx.customerId, plan: ctx.plan, feature: ctx.feature, period: d.period,
|
|
147
|
+
requestedModel, model: d.model, downgraded: d.downgraded,
|
|
148
|
+
promptTokens: u.prompt_tokens ?? 0, completionTokens: u.completion_tokens ?? 0,
|
|
149
|
+
cachedTokens: u.prompt_tokens_details?.cached_tokens ?? 0,
|
|
150
|
+
costUsd: cost, spentUsd: spent, budgetUsd: d.budget, timestamp: now().toISOString(),
|
|
151
|
+
};
|
|
152
|
+
if (opts.onUsage) {
|
|
153
|
+
try {
|
|
154
|
+
await opts.onUsage(event);
|
|
155
|
+
}
|
|
156
|
+
catch { /* never break the caller */ }
|
|
157
|
+
}
|
|
158
|
+
return event;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Wrap an OpenAI-compatible `chat.completions.create` function.
|
|
162
|
+
* const create = guard.wrap(openai.chat.completions.create.bind(openai.chat.completions));
|
|
163
|
+
* await create({ model, messages }, { customerId: "cus_123" });
|
|
164
|
+
*/
|
|
165
|
+
function wrap(createFn) {
|
|
166
|
+
return async function guardedCreate(params, ctx, ...rest) {
|
|
167
|
+
if (!ctx?.customerId)
|
|
168
|
+
throw new TypeError("llmbrake: ctx.customerId is required");
|
|
169
|
+
const requestedModel = params.model;
|
|
170
|
+
const d = isReservingStore(store) ? await reserveCheck(ctx, params) : await check(ctx, requestedModel);
|
|
171
|
+
const finalParams = { ...params, model: d.model };
|
|
172
|
+
if (params.stream) {
|
|
173
|
+
finalParams.stream_options = { ...(params.stream_options ?? {}), include_usage: true };
|
|
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
|
+
}
|
|
182
|
+
return wrapStream(stream, (model, usage) => record(ctx, d, requestedModel, model, usage));
|
|
183
|
+
}
|
|
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
|
+
}
|
|
192
|
+
await record(ctx, d, requestedModel, res?.model, res?.usage);
|
|
193
|
+
return res;
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
/** Current spend/budget for a customer this period. */
|
|
197
|
+
async function status(ctx) {
|
|
198
|
+
const period = periodOf(now());
|
|
199
|
+
const budget = await budgetFor(ctx);
|
|
200
|
+
const spent = await safeGetSpend(ctx.customerId, period);
|
|
201
|
+
return { period, spentUsd: spent, budgetUsd: budget, remainingUsd: Math.max(0, budget - spent) };
|
|
202
|
+
}
|
|
203
|
+
return { wrap, check, status };
|
|
204
|
+
}
|
|
205
|
+
function wrapStream(stream, onDone) {
|
|
206
|
+
const proxy = {
|
|
207
|
+
async *[Symbol.asyncIterator]() {
|
|
208
|
+
let usage;
|
|
209
|
+
let model;
|
|
210
|
+
try {
|
|
211
|
+
for await (const chunk of stream) {
|
|
212
|
+
if (chunk?.usage)
|
|
213
|
+
usage = chunk.usage;
|
|
214
|
+
if (chunk?.model)
|
|
215
|
+
model = chunk.model;
|
|
216
|
+
yield chunk;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
finally {
|
|
220
|
+
await onDone(model, usage);
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
// Preserve other properties (e.g. controller) of SDK stream objects.
|
|
225
|
+
return Object.assign(Object.create(Object.getPrototypeOf(stream) ?? null), stream, { [Symbol.asyncIterator]: proxy[Symbol.asyncIterator] });
|
|
226
|
+
}
|
package/dist/hosted.d.ts
ADDED
|
@@ -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
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { createGuard, periodOf, estimateCallUsd } from "./guard.js";
|
|
2
|
+
export type { GuardOptions, CallContext, UsageEvent } from "./guard.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";
|
|
7
|
+
export { DEFAULT_PRICES, computeCostUsd, lookupPrice } from "./prices.js";
|
|
8
|
+
export type { ModelPrice, PriceTable, ChatUsage } from "./prices.js";
|
|
9
|
+
export { BudgetExceededError, UnknownModelPriceError, isBudgetExceededError } from "./errors.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
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";
|
|
4
|
+
export { DEFAULT_PRICES, computeCostUsd, lookupPrice } from "./prices.js";
|
|
5
|
+
export { BudgetExceededError, UnknownModelPriceError, isBudgetExceededError } from "./errors.js";
|
package/dist/prices.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default price table, USD per 1M tokens, standard (non-batch, short-context) tier.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ NEEDS VERIFICATION before production use. Provider prices change often.
|
|
5
|
+
* Values were read on 2026-10-09 from:
|
|
6
|
+
* - OpenAI: https://developers.openai.com/api/docs/pricing
|
|
7
|
+
* - Anthropic: https://docs.anthropic.com/en/docs/about-claude/pricing
|
|
8
|
+
* Anthropic model IDs below are illustrative; confirm the exact IDs your endpoint accepts.
|
|
9
|
+
* Long-context, batch, regional/data-residency uplifts and fast/priority tiers are NOT modeled.
|
|
10
|
+
* Override or extend via `createGuard({ prices: { ...DEFAULT_PRICES, ...yours } })`.
|
|
11
|
+
*/
|
|
12
|
+
export interface ModelPrice {
|
|
13
|
+
/** USD per 1M uncached input tokens */
|
|
14
|
+
inputPerMTok: number;
|
|
15
|
+
/** USD per 1M output tokens */
|
|
16
|
+
outputPerMTok: number;
|
|
17
|
+
/** USD per 1M cached input tokens (defaults to inputPerMTok if omitted) */
|
|
18
|
+
cachedInputPerMTok?: number;
|
|
19
|
+
/** Where the number came from */
|
|
20
|
+
source?: string;
|
|
21
|
+
}
|
|
22
|
+
export type PriceTable = Record<string, ModelPrice>;
|
|
23
|
+
export declare const DEFAULT_PRICES: PriceTable;
|
|
24
|
+
/** OpenAI-compatible usage object (chat.completions). */
|
|
25
|
+
export interface ChatUsage {
|
|
26
|
+
prompt_tokens?: number;
|
|
27
|
+
completion_tokens?: number;
|
|
28
|
+
prompt_tokens_details?: {
|
|
29
|
+
cached_tokens?: number;
|
|
30
|
+
} | null;
|
|
31
|
+
}
|
|
32
|
+
/** Returns USD cost, or null if the model is not in the table. */
|
|
33
|
+
export declare function computeCostUsd(model: string, usage: ChatUsage, prices: PriceTable): number | null;
|
|
34
|
+
/** Exact match first, then strips a trailing date snapshot like "-2025-04-14". */
|
|
35
|
+
export declare function lookupPrice(model: string, prices: PriceTable): ModelPrice | undefined;
|
package/dist/prices.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const OPENAI = "https://developers.openai.com/api/docs/pricing (read 2026-10-09, verify)";
|
|
2
|
+
const ANTHROPIC = "https://docs.anthropic.com/en/docs/about-claude/pricing (read 2026-10-09, verify)";
|
|
3
|
+
export const DEFAULT_PRICES = {
|
|
4
|
+
"gpt-6.1-sol": { inputPerMTok: 2.0, cachedInputPerMTok: 0.1, outputPerMTok: 10.0, source: OPENAI },
|
|
5
|
+
"gpt-6-luna": { inputPerMTok: 0.1, cachedInputPerMTok: 0.01, outputPerMTok: 0.5, source: OPENAI },
|
|
6
|
+
"gpt-5.5": { inputPerMTok: 5.0, cachedInputPerMTok: 0.5, outputPerMTok: 30.0, source: OPENAI },
|
|
7
|
+
"gpt-5.4": { inputPerMTok: 2.5, cachedInputPerMTok: 0.25, outputPerMTok: 15.0, source: OPENAI },
|
|
8
|
+
"gpt-5.4-mini": { inputPerMTok: 0.75, cachedInputPerMTok: 0.075, outputPerMTok: 4.5, source: OPENAI },
|
|
9
|
+
"gpt-5.4-nano": { inputPerMTok: 0.2, cachedInputPerMTok: 0.02, outputPerMTok: 1.25, source: OPENAI },
|
|
10
|
+
"gpt-4.1-mini": { inputPerMTok: 0.4, cachedInputPerMTok: 0.1, outputPerMTok: 1.6, source: OPENAI },
|
|
11
|
+
"gpt-4o-mini": { inputPerMTok: 0.15, cachedInputPerMTok: 0.075, outputPerMTok: 0.6, source: OPENAI },
|
|
12
|
+
"claude-opus-4-8": { inputPerMTok: 5.0, cachedInputPerMTok: 0.5, outputPerMTok: 25.0, source: ANTHROPIC },
|
|
13
|
+
"claude-sonnet-5": { inputPerMTok: 2.0, cachedInputPerMTok: 0.2, outputPerMTok: 10.0, source: ANTHROPIC },
|
|
14
|
+
"claude-haiku-4-5": { inputPerMTok: 1.0, cachedInputPerMTok: 0.1, outputPerMTok: 5.0, source: ANTHROPIC },
|
|
15
|
+
};
|
|
16
|
+
/** Returns USD cost, or null if the model is not in the table. */
|
|
17
|
+
export function computeCostUsd(model, usage, prices) {
|
|
18
|
+
const p = lookupPrice(model, prices);
|
|
19
|
+
if (!p)
|
|
20
|
+
return null;
|
|
21
|
+
const prompt = usage.prompt_tokens ?? 0;
|
|
22
|
+
const cached = Math.min(usage.prompt_tokens_details?.cached_tokens ?? 0, prompt);
|
|
23
|
+
const uncached = prompt - cached;
|
|
24
|
+
const out = usage.completion_tokens ?? 0;
|
|
25
|
+
const cachedRate = p.cachedInputPerMTok ?? p.inputPerMTok;
|
|
26
|
+
return (uncached * p.inputPerMTok + cached * cachedRate + out * p.outputPerMTok) / 1_000_000;
|
|
27
|
+
}
|
|
28
|
+
/** Exact match first, then strips a trailing date snapshot like "-2025-04-14". */
|
|
29
|
+
export function lookupPrice(model, prices) {
|
|
30
|
+
if (prices[model])
|
|
31
|
+
return prices[model];
|
|
32
|
+
const base = model.replace(/-\d{4}-\d{2}-\d{2}$/, "");
|
|
33
|
+
return prices[base];
|
|
34
|
+
}
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** Pluggable spend storage. Implement this with Redis/Postgres/etc. for multi-instance apps. */
|
|
2
|
+
export interface UsageStore {
|
|
3
|
+
/** Current spend in USD for a customer in a period (e.g. "2026-10"). */
|
|
4
|
+
getSpend(customerId: string, period: string): Promise<number>;
|
|
5
|
+
/** Atomically add USD and return the new total. */
|
|
6
|
+
addSpend(customerId: string, period: string, usd: number): Promise<number>;
|
|
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;
|
|
48
|
+
/** In-memory store. Fine for a single process, tests and demos; state is lost on restart. */
|
|
49
|
+
export declare class MemoryStore implements UsageStore {
|
|
50
|
+
private data;
|
|
51
|
+
private key;
|
|
52
|
+
getSpend(customerId: string, period: string): Promise<number>;
|
|
53
|
+
addSpend(customerId: string, period: string, usd: number): Promise<number>;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Example Redis adapter shape (not bundled, to keep zero runtime deps):
|
|
57
|
+
*
|
|
58
|
+
* const redisStore: UsageStore = {
|
|
59
|
+
* async getSpend(c, p) { return Number(await redis.get(`llmbrake:${p}:${c}`) ?? 0); },
|
|
60
|
+
* async addSpend(c, p, usd) { return Number(await redis.incrbyfloat(`llmbrake:${p}:${c}`, usd)); },
|
|
61
|
+
* };
|
|
62
|
+
*/
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export function isReservingStore(s) {
|
|
2
|
+
return typeof s.reserve === "function" && typeof s.settle === "function";
|
|
3
|
+
}
|
|
4
|
+
/** In-memory store. Fine for a single process, tests and demos; state is lost on restart. */
|
|
5
|
+
export class MemoryStore {
|
|
6
|
+
data = new Map();
|
|
7
|
+
key(c, p) { return `${p}:${c}`; }
|
|
8
|
+
async getSpend(customerId, period) {
|
|
9
|
+
return this.data.get(this.key(customerId, period)) ?? 0;
|
|
10
|
+
}
|
|
11
|
+
async addSpend(customerId, period, usd) {
|
|
12
|
+
const k = this.key(customerId, period);
|
|
13
|
+
const next = (this.data.get(k) ?? 0) + usd;
|
|
14
|
+
this.data.set(k, next);
|
|
15
|
+
return next;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Example Redis adapter shape (not bundled, to keep zero runtime deps):
|
|
20
|
+
*
|
|
21
|
+
* const redisStore: UsageStore = {
|
|
22
|
+
* async getSpend(c, p) { return Number(await redis.get(`llmbrake:${p}:${c}`) ?? 0); },
|
|
23
|
+
* async addSpend(c, p, usd) { return Number(await redis.incrbyfloat(`llmbrake:${p}:${c}`, usd)); },
|
|
24
|
+
* };
|
|
25
|
+
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "llmbrake",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Per-customer monthly USD budgets for OpenAI-compatible chat calls: track cost, auto-downgrade near the cap, block at the cap.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "LLMBrake contributors",
|
|
7
|
+
"homepage": "https://llmbrake.com",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/llmbrake/llmbrake.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/llmbrake/llmbrake/issues"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"main": "./dist/index.js",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"import": "./dist/index.js",
|
|
22
|
+
"default": "./dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./package.json": "./package.json"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"sideEffects": false,
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=18"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"clean": "node -e \"for (const d of ['dist','build-test']) require('node:fs').rmSync(d, { recursive: true, force: true })\"",
|
|
37
|
+
"build": "npm run clean && tsc -p tsconfig.json",
|
|
38
|
+
"test": "npm run build && tsc -p tsconfig.test.json && node --test build-test/test/*.test.js",
|
|
39
|
+
"prepare": "npm run build",
|
|
40
|
+
"prepublishOnly": "npm test"
|
|
41
|
+
},
|
|
42
|
+
"keywords": [
|
|
43
|
+
"openai",
|
|
44
|
+
"anthropic",
|
|
45
|
+
"llm",
|
|
46
|
+
"ai",
|
|
47
|
+
"cost",
|
|
48
|
+
"budget",
|
|
49
|
+
"usage",
|
|
50
|
+
"quota",
|
|
51
|
+
"rate-limit",
|
|
52
|
+
"saas"
|
|
53
|
+
],
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@types/node": "^20.0.0",
|
|
56
|
+
"typescript": "^5.6.0"
|
|
57
|
+
}
|
|
58
|
+
}
|