llmbrake 0.0.0-stage → 0.1.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 +176 -2
- package/dist/errors.d.ts +14 -0
- package/dist/errors.js +27 -0
- package/dist/guard.d.ts +69 -0
- package/dist/guard.js +136 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +4 -0
- package/dist/prices.d.ts +35 -0
- package/dist/prices.js +34 -0
- package/dist/store.d.ts +22 -0
- package/dist/store.js +22 -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,177 @@
|
|
|
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://github.com/llmbrake/llmbrake/actions/workflows/ci.yml)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
`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.
|
|
9
|
+
|
|
10
|
+
- **Not a proxy.** It runs inside your app and calls your provider directly with your own keys.
|
|
11
|
+
- **Metadata only.** It never reads or stores prompts or completions, only customer ID, plan, feature tag, model, token counts, cost and timestamps.
|
|
12
|
+
- **Zero runtime dependencies.** ESM, Node 18+, MIT licensed.
|
|
13
|
+
|
|
14
|
+
Status: `0.1.0`, early. Website: [llmbrake.com](https://llmbrake.com)
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install llmbrake
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`llmbrake` is ESM-only (`import`, not `require`).
|
|
23
|
+
|
|
24
|
+
## 30-second quickstart
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import OpenAI from "openai";
|
|
28
|
+
import { createGuard, isBudgetExceededError } from "llmbrake";
|
|
29
|
+
|
|
30
|
+
const openai = new OpenAI();
|
|
31
|
+
|
|
32
|
+
const guard = createGuard({
|
|
33
|
+
budgetUsd: (ctx) => (ctx.plan === "pro" ? 20 : 2), // USD per customer per calendar month (UTC)
|
|
34
|
+
downgradeAt: 0.8, // at 80% of the budget...
|
|
35
|
+
downgradeMap: { "gpt-5.5": "gpt-5.4-mini" }, // ...send gpt-5.5 requests to gpt-5.4-mini
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const chat = openai.chat.completions;
|
|
39
|
+
const create = guard.wrap(chat.create.bind(chat));
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const res = await create(
|
|
43
|
+
{ model: "gpt-5.5", messages: [{ role: "user", content: "Hello" }] },
|
|
44
|
+
{ customerId: user.id, plan: user.plan, feature: "chat" },
|
|
45
|
+
);
|
|
46
|
+
} catch (e) {
|
|
47
|
+
if (isBudgetExceededError(e)) {
|
|
48
|
+
// e.customerId, e.spentUsd, e.budgetUsd, e.period
|
|
49
|
+
return showUpgradePrompt();
|
|
50
|
+
}
|
|
51
|
+
throw e;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
await guard.status({ customerId: user.id, plan: user.plan });
|
|
55
|
+
// → { period: "2026-10", spentUsd: …, budgetUsd: 20, remainingUsd: … }
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## How it works
|
|
59
|
+
|
|
60
|
+
Every wrapped call goes through three steps:
|
|
61
|
+
|
|
62
|
+
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`.
|
|
63
|
+
2. **Decide.**
|
|
64
|
+
- **Block:** if `spent >= budget`, it throws `BudgetExceededError` (`code: "LLMBRAKE_BUDGET_EXCEEDED"`) and **the provider is never called**. Catch it to show an upgrade prompt.
|
|
65
|
+
- **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`.
|
|
66
|
+
- Otherwise the request goes through unchanged.
|
|
67
|
+
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:
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
{
|
|
71
|
+
customerId: "cus_123", plan: "pro", feature: "chat", period: "2026-10",
|
|
72
|
+
requestedModel: "gpt-5.5", model: "gpt-5.4-mini", downgraded: true,
|
|
73
|
+
promptTokens: 1102, completionTokens: 182, cachedTokens: 640,
|
|
74
|
+
costUsd: 0.0012, spentUsd: 16.41, budgetUsd: 20, timestamp: "2026-10-11T20:00:00.000Z"
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`guard.check(ctx, model)` runs steps 1–2 without making a call, which is handy for disabling a button before the user clicks it.
|
|
79
|
+
|
|
80
|
+
### Streaming
|
|
81
|
+
|
|
82
|
+
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).
|
|
83
|
+
|
|
84
|
+
### Other OpenAI-compatible endpoints
|
|
85
|
+
|
|
86
|
+
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
|
+
|
|
88
|
+
## Storage (Redis example)
|
|
89
|
+
|
|
90
|
+
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):
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
import Redis from "ioredis";
|
|
94
|
+
import { createGuard, type UsageStore } from "llmbrake";
|
|
95
|
+
|
|
96
|
+
const redis = new Redis(process.env.REDIS_URL!);
|
|
97
|
+
const key = (customerId: string, period: string) => `llmbrake:${period}:${customerId}`;
|
|
98
|
+
|
|
99
|
+
const redisStore: UsageStore = {
|
|
100
|
+
async getSpend(customerId, period) {
|
|
101
|
+
return Number((await redis.get(key(customerId, period))) ?? 0);
|
|
102
|
+
},
|
|
103
|
+
async addSpend(customerId, period, usd) {
|
|
104
|
+
const k = key(customerId, period);
|
|
105
|
+
const res = await redis
|
|
106
|
+
.multi()
|
|
107
|
+
.incrbyfloat(k, usd)
|
|
108
|
+
.expire(k, 60 * 60 * 24 * 40) // keep ~40 days, then let old months expire
|
|
109
|
+
.exec();
|
|
110
|
+
const [err, total] = res?.[0] ?? [new Error("Redis transaction failed"), null];
|
|
111
|
+
if (err) throw err;
|
|
112
|
+
return Number(total);
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const guard = createGuard({ budgetUsd: 5, store: redisStore });
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`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
|
+
|
|
121
|
+
## Options
|
|
122
|
+
|
|
123
|
+
`createGuard(options)` returns `{ wrap, check, status }`.
|
|
124
|
+
|
|
125
|
+
| Option | Default | Description |
|
|
126
|
+
|---|---|---|
|
|
127
|
+
| `budgetUsd` | required | Monthly USD budget: a number, or `(ctx) => number \| Promise<number>`. Must be `>= 0`; use `Infinity` for unlimited. |
|
|
128
|
+
| `store` | `new MemoryStore()` | Where spend is kept. Any `UsageStore` (`getSpend`, `addSpend`). |
|
|
129
|
+
| `prices` | `DEFAULT_PRICES` | Model → `{ inputPerMTok, outputPerMTok, cachedInputPerMTok?, source? }`, USD per 1M tokens. |
|
|
130
|
+
| `downgradeAt` | `0.8` | Fraction of the budget (0–1) at which `downgradeMap` applies. `1` disables downgrading. |
|
|
131
|
+
| `downgradeMap` | `{}` | `{ "expensive-model": "cheaper-model" }`. |
|
|
132
|
+
| `onUsage` | none | `(event) => void \| Promise<void>`, called after each metered call. Errors in it are swallowed. |
|
|
133
|
+
| `unknownModel` | `"throw"` | Model missing from `prices`: `"throw"` an `UnknownModelPriceError` before the call, or `"allow"` it and record `costUsd: null`. |
|
|
134
|
+
| `failOpen` | `true` | If the store errors: `true` lets calls through, `false` rethrows. |
|
|
135
|
+
| `now` | `() => new Date()` | Clock override, mainly for tests. |
|
|
136
|
+
|
|
137
|
+
The call context passed as the second argument to the wrapped function is `{ customerId: string; plan?: string; feature?: string }`.
|
|
138
|
+
|
|
139
|
+
## Model prices: verify before relying on them
|
|
140
|
+
|
|
141
|
+
`DEFAULT_PRICES` covers a few common OpenAI and Anthropic models in USD per 1M tokens (standard tier), read on 2026-10-09 from:
|
|
142
|
+
|
|
143
|
+
- OpenAI: https://developers.openai.com/api/docs/pricing
|
|
144
|
+
- Anthropic: https://docs.anthropic.com/en/docs/about-claude/pricing
|
|
145
|
+
|
|
146
|
+
**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:
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
import { createGuard, DEFAULT_PRICES } from "llmbrake";
|
|
150
|
+
|
|
151
|
+
createGuard({
|
|
152
|
+
budgetUsd: 5,
|
|
153
|
+
prices: { ...DEFAULT_PRICES, "my-model": { inputPerMTok: 1, outputPerMTok: 3 } },
|
|
154
|
+
});
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Dated snapshot IDs such as `gpt-5.4-mini-2026-03-17` fall back to the undated entry.
|
|
158
|
+
|
|
159
|
+
## Known limitations
|
|
160
|
+
|
|
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.
|
|
162
|
+
- **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
|
+
- **Chat completions only.** Only chat-completions-style token usage is metered: no images, audio, embeddings or tool fees yet.
|
|
164
|
+
- **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
|
+
- **UTC calendar months.** Budget periods reset at 00:00 UTC on the 1st.
|
|
166
|
+
|
|
167
|
+
## Hosted dashboard (coming)
|
|
168
|
+
|
|
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).
|
|
170
|
+
|
|
171
|
+
## Contributing
|
|
172
|
+
|
|
173
|
+
Issues and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md). Security issues: see [SECURITY.md](SECURITY.md).
|
|
174
|
+
|
|
175
|
+
## License
|
|
176
|
+
|
|
177
|
+
[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,69 @@
|
|
|
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
|
+
type ChatParams = {
|
|
46
|
+
model: string;
|
|
47
|
+
stream?: boolean | null;
|
|
48
|
+
stream_options?: Record<string, unknown> | null;
|
|
49
|
+
[k: string]: unknown;
|
|
50
|
+
};
|
|
51
|
+
type CreateFn<P, R> = (params: P, ...rest: any[]) => Promise<R>;
|
|
52
|
+
export declare function periodOf(d: Date): string;
|
|
53
|
+
export declare function createGuard(opts: GuardOptions): {
|
|
54
|
+
wrap: <P extends ChatParams, R>(createFn: CreateFn<P, R>) => (params: P, ctx: CallContext, ...rest: any[]) => Promise<R>;
|
|
55
|
+
check: (ctx: CallContext, model: string) => Promise<{
|
|
56
|
+
period: string;
|
|
57
|
+
budget: number;
|
|
58
|
+
spent: number;
|
|
59
|
+
model: string;
|
|
60
|
+
downgraded: boolean;
|
|
61
|
+
}>;
|
|
62
|
+
status: (ctx: CallContext) => Promise<{
|
|
63
|
+
period: string;
|
|
64
|
+
spentUsd: number;
|
|
65
|
+
budgetUsd: number;
|
|
66
|
+
remainingUsd: number;
|
|
67
|
+
}>;
|
|
68
|
+
};
|
|
69
|
+
export {};
|
package/dist/guard.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { BudgetExceededError, UnknownModelPriceError } from "./errors.js";
|
|
2
|
+
import { DEFAULT_PRICES, computeCostUsd } from "./prices.js";
|
|
3
|
+
import { MemoryStore } from "./store.js";
|
|
4
|
+
export function periodOf(d) {
|
|
5
|
+
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
|
|
6
|
+
}
|
|
7
|
+
export function createGuard(opts) {
|
|
8
|
+
const store = opts.store ?? new MemoryStore();
|
|
9
|
+
const prices = opts.prices ?? DEFAULT_PRICES;
|
|
10
|
+
const downgradeAt = opts.downgradeAt ?? 0.8;
|
|
11
|
+
const downgradeMap = opts.downgradeMap ?? {};
|
|
12
|
+
const unknownModel = opts.unknownModel ?? "throw";
|
|
13
|
+
const failOpen = opts.failOpen ?? true;
|
|
14
|
+
const now = opts.now ?? (() => new Date());
|
|
15
|
+
if (typeof downgradeAt !== "number" || !(downgradeAt >= 0 && downgradeAt <= 1)) {
|
|
16
|
+
throw new RangeError(`llmbrake: downgradeAt must be a number between 0 and 1, got ${String(downgradeAt)}`);
|
|
17
|
+
}
|
|
18
|
+
async function budgetFor(ctx) {
|
|
19
|
+
const b = typeof opts.budgetUsd === "function" ? await opts.budgetUsd(ctx) : opts.budgetUsd;
|
|
20
|
+
// NaN or a negative budget would silently disable the cap (spent >= NaN is always false), so reject it.
|
|
21
|
+
if (typeof b !== "number" || Number.isNaN(b) || b < 0) {
|
|
22
|
+
throw new TypeError(`llmbrake: budgetUsd for customer ${ctx.customerId} must be a number >= 0 (or Infinity), got ${String(b)}`);
|
|
23
|
+
}
|
|
24
|
+
return b;
|
|
25
|
+
}
|
|
26
|
+
async function safeGetSpend(c, p) {
|
|
27
|
+
try {
|
|
28
|
+
const spent = await store.getSpend(c, p);
|
|
29
|
+
// A store returning NaN/garbage would also silently disable the cap; treat it as a store failure.
|
|
30
|
+
if (typeof spent !== "number" || !Number.isFinite(spent))
|
|
31
|
+
throw new TypeError(`llmbrake: store.getSpend returned a non-numeric value (${String(spent)})`);
|
|
32
|
+
return spent;
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
if (failOpen)
|
|
36
|
+
return 0;
|
|
37
|
+
throw e;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Decide whether a call may proceed, and with which model. Throws BudgetExceededError at the cap. */
|
|
41
|
+
async function check(ctx, model) {
|
|
42
|
+
const period = periodOf(now());
|
|
43
|
+
const budget = await budgetFor(ctx);
|
|
44
|
+
const spent = await safeGetSpend(ctx.customerId, period);
|
|
45
|
+
if (spent >= budget)
|
|
46
|
+
throw new BudgetExceededError(ctx.customerId, spent, budget, period);
|
|
47
|
+
let effective = model;
|
|
48
|
+
if (budget !== Infinity && spent >= budget * downgradeAt && downgradeMap[model])
|
|
49
|
+
effective = downgradeMap[model];
|
|
50
|
+
if (unknownModel === "throw" && computeCostUsd(effective, {}, prices) === null)
|
|
51
|
+
throw new UnknownModelPriceError(effective);
|
|
52
|
+
return { period, budget, spent, model: effective, downgraded: effective !== model };
|
|
53
|
+
}
|
|
54
|
+
async function record(ctx, d, requestedModel, respModel, usage) {
|
|
55
|
+
const u = usage ?? {};
|
|
56
|
+
// Price by the model the provider reports (dated snapshots resolve via lookupPrice); fall back to the model we sent.
|
|
57
|
+
const priceModel = respModel && computeCostUsd(respModel, {}, prices) !== null ? respModel : d.model;
|
|
58
|
+
const cost = computeCostUsd(priceModel, u, prices);
|
|
59
|
+
let spent = d.spent;
|
|
60
|
+
if (cost !== null && cost > 0) {
|
|
61
|
+
try {
|
|
62
|
+
spent = await store.addSpend(ctx.customerId, d.period, cost);
|
|
63
|
+
}
|
|
64
|
+
catch (e) {
|
|
65
|
+
if (!failOpen)
|
|
66
|
+
throw e;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const event = {
|
|
70
|
+
customerId: ctx.customerId, plan: ctx.plan, feature: ctx.feature, period: d.period,
|
|
71
|
+
requestedModel, model: d.model, downgraded: d.downgraded,
|
|
72
|
+
promptTokens: u.prompt_tokens ?? 0, completionTokens: u.completion_tokens ?? 0,
|
|
73
|
+
cachedTokens: u.prompt_tokens_details?.cached_tokens ?? 0,
|
|
74
|
+
costUsd: cost, spentUsd: spent, budgetUsd: d.budget, timestamp: now().toISOString(),
|
|
75
|
+
};
|
|
76
|
+
if (opts.onUsage) {
|
|
77
|
+
try {
|
|
78
|
+
await opts.onUsage(event);
|
|
79
|
+
}
|
|
80
|
+
catch { /* never break the caller */ }
|
|
81
|
+
}
|
|
82
|
+
return event;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Wrap an OpenAI-compatible `chat.completions.create` function.
|
|
86
|
+
* const create = guard.wrap(openai.chat.completions.create.bind(openai.chat.completions));
|
|
87
|
+
* await create({ model, messages }, { customerId: "cus_123" });
|
|
88
|
+
*/
|
|
89
|
+
function wrap(createFn) {
|
|
90
|
+
return async function guardedCreate(params, ctx, ...rest) {
|
|
91
|
+
if (!ctx?.customerId)
|
|
92
|
+
throw new TypeError("llmbrake: ctx.customerId is required");
|
|
93
|
+
const requestedModel = params.model;
|
|
94
|
+
const d = await check(ctx, requestedModel);
|
|
95
|
+
const finalParams = { ...params, model: d.model };
|
|
96
|
+
if (params.stream) {
|
|
97
|
+
finalParams.stream_options = { ...(params.stream_options ?? {}), include_usage: true };
|
|
98
|
+
const stream = (await createFn(finalParams, ...rest));
|
|
99
|
+
return wrapStream(stream, (model, usage) => record(ctx, d, requestedModel, model, usage));
|
|
100
|
+
}
|
|
101
|
+
const res = await createFn(finalParams, ...rest);
|
|
102
|
+
await record(ctx, d, requestedModel, res?.model, res?.usage);
|
|
103
|
+
return res;
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/** Current spend/budget for a customer this period. */
|
|
107
|
+
async function status(ctx) {
|
|
108
|
+
const period = periodOf(now());
|
|
109
|
+
const budget = await budgetFor(ctx);
|
|
110
|
+
const spent = await safeGetSpend(ctx.customerId, period);
|
|
111
|
+
return { period, spentUsd: spent, budgetUsd: budget, remainingUsd: Math.max(0, budget - spent) };
|
|
112
|
+
}
|
|
113
|
+
return { wrap, check, status };
|
|
114
|
+
}
|
|
115
|
+
function wrapStream(stream, onDone) {
|
|
116
|
+
const proxy = {
|
|
117
|
+
async *[Symbol.asyncIterator]() {
|
|
118
|
+
let usage;
|
|
119
|
+
let model;
|
|
120
|
+
try {
|
|
121
|
+
for await (const chunk of stream) {
|
|
122
|
+
if (chunk?.usage)
|
|
123
|
+
usage = chunk.usage;
|
|
124
|
+
if (chunk?.model)
|
|
125
|
+
model = chunk.model;
|
|
126
|
+
yield chunk;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
await onDone(model, usage);
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
// Preserve other properties (e.g. controller) of SDK stream objects.
|
|
135
|
+
return Object.assign(Object.create(Object.getPrototypeOf(stream) ?? null), stream, { [Symbol.asyncIterator]: proxy[Symbol.asyncIterator] });
|
|
136
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { createGuard, periodOf } from "./guard.js";
|
|
2
|
+
export type { GuardOptions, CallContext, UsageEvent } from "./guard.js";
|
|
3
|
+
export { MemoryStore } from "./store.js";
|
|
4
|
+
export type { UsageStore } from "./store.js";
|
|
5
|
+
export { DEFAULT_PRICES, computeCostUsd, lookupPrice } from "./prices.js";
|
|
6
|
+
export type { ModelPrice, PriceTable, ChatUsage } from "./prices.js";
|
|
7
|
+
export { BudgetExceededError, UnknownModelPriceError, isBudgetExceededError } from "./errors.js";
|
package/dist/index.js
ADDED
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,22 @@
|
|
|
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
|
+
/** In-memory store. Fine for a single process, tests and demos; state is lost on restart. */
|
|
9
|
+
export declare class MemoryStore implements UsageStore {
|
|
10
|
+
private data;
|
|
11
|
+
private key;
|
|
12
|
+
getSpend(customerId: string, period: string): Promise<number>;
|
|
13
|
+
addSpend(customerId: string, period: string, usd: number): Promise<number>;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Example Redis adapter shape (not bundled, to keep zero runtime deps):
|
|
17
|
+
*
|
|
18
|
+
* const redisStore: UsageStore = {
|
|
19
|
+
* async getSpend(c, p) { return Number(await redis.get(`llmbrake:${p}:${c}`) ?? 0); },
|
|
20
|
+
* async addSpend(c, p, usd) { return Number(await redis.incrbyfloat(`llmbrake:${p}:${c}`, usd)); },
|
|
21
|
+
* };
|
|
22
|
+
*/
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** In-memory store. Fine for a single process, tests and demos; state is lost on restart. */
|
|
2
|
+
export class MemoryStore {
|
|
3
|
+
data = new Map();
|
|
4
|
+
key(c, p) { return `${p}:${c}`; }
|
|
5
|
+
async getSpend(customerId, period) {
|
|
6
|
+
return this.data.get(this.key(customerId, period)) ?? 0;
|
|
7
|
+
}
|
|
8
|
+
async addSpend(customerId, period, usd) {
|
|
9
|
+
const k = this.key(customerId, period);
|
|
10
|
+
const next = (this.data.get(k) ?? 0) + usd;
|
|
11
|
+
this.data.set(k, next);
|
|
12
|
+
return next;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Example Redis adapter shape (not bundled, to keep zero runtime deps):
|
|
17
|
+
*
|
|
18
|
+
* const redisStore: UsageStore = {
|
|
19
|
+
* async getSpend(c, p) { return Number(await redis.get(`llmbrake:${p}:${c}`) ?? 0); },
|
|
20
|
+
* async addSpend(c, p, usd) { return Number(await redis.incrbyfloat(`llmbrake:${p}:${c}`, usd)); },
|
|
21
|
+
* };
|
|
22
|
+
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "llmbrake",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.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
|
+
}
|