budget-guard 0.1.1 → 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 +49 -4
- package/dist/guard.d.ts +5 -4
- package/dist/guard.js +23 -24
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/store.d.ts +43 -0
- package/dist/store.js +57 -0
- package/dist/types.d.ts +15 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -56,10 +56,47 @@ guard(client, opts, { usageOf: (res) => ({ input: res.in, output: res.out }) });
|
|
|
56
56
|
```ts
|
|
57
57
|
import { spendReport } from 'budget-guard';
|
|
58
58
|
|
|
59
|
-
spendReport('my-app');
|
|
59
|
+
await spendReport('my-app'); // async
|
|
60
60
|
// → { chat: 2.41, summarize: 0.88 } (today, in USD)
|
|
61
61
|
```
|
|
62
62
|
|
|
63
|
+
## Shared caps across instances (Redis)
|
|
64
|
+
|
|
65
|
+
By default the ledger lives in memory (per process) — great for a single script, worker, or agent. Running multiple instances? Pass a shared store so they enforce **one cap together** and survive restarts:
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
import { createClient } from 'redis';
|
|
69
|
+
import { guard, redisStore } from 'budget-guard';
|
|
70
|
+
|
|
71
|
+
const redis = createClient();
|
|
72
|
+
await redis.connect();
|
|
73
|
+
|
|
74
|
+
const ai = guard(openai.chat.completions, {
|
|
75
|
+
project: 'my-app',
|
|
76
|
+
dailyCapUSD: 50,
|
|
77
|
+
store: redisStore(redis), // node-redis v4; keys auto-expire (~2 days)
|
|
78
|
+
});
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`store` accepts anything implementing the tiny `SpendStore` interface (`add` / `get` / `entries`), so you can back it with whatever you already run.
|
|
82
|
+
|
|
83
|
+
## Block *before* the call (no overshoot)
|
|
84
|
+
|
|
85
|
+
By default the cap is enforced on the **next** call after you cross it, so one call can overshoot. Give it an estimator and it blocks the offending call itself:
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
import { encode } from 'gpt-tokenizer'; // or any tokenizer
|
|
89
|
+
|
|
90
|
+
const ai = guard(openai.chat.completions, {
|
|
91
|
+
project: 'my-app',
|
|
92
|
+
dailyCapUSD: 50,
|
|
93
|
+
estimateUsage: (args) => ({
|
|
94
|
+
input: args.messages.reduce((n, m) => n + encode(m.content).length, 0),
|
|
95
|
+
output: args.max_tokens ?? 512,
|
|
96
|
+
}),
|
|
97
|
+
});
|
|
98
|
+
```
|
|
99
|
+
|
|
63
100
|
## Options
|
|
64
101
|
|
|
65
102
|
```ts
|
|
@@ -67,6 +104,8 @@ guard(client, {
|
|
|
67
104
|
project: 'my-app', // groups spend & shares one cap
|
|
68
105
|
dailyCapUSD: 50, // hard cap per day
|
|
69
106
|
onCap: 'block', // 'block' (throw) | 'warn' (log only). default 'block'
|
|
107
|
+
store: myStore, // optional SpendStore (default: in-memory, per-process)
|
|
108
|
+
estimateUsage: fn, // optional: block before a call would exceed the cap
|
|
70
109
|
});
|
|
71
110
|
```
|
|
72
111
|
|
|
@@ -77,11 +116,17 @@ const ai = guard(openai.chat.completions, { project: 'my-app', dailyCapUSD: 50,
|
|
|
77
116
|
// over cap → logs a warning, still calls. Good for easing in.
|
|
78
117
|
```
|
|
79
118
|
|
|
80
|
-
## Notes (v0.
|
|
119
|
+
## Notes (v0.2)
|
|
81
120
|
|
|
82
|
-
-
|
|
83
|
-
-
|
|
121
|
+
- **Multi-instance + persistence** via a pluggable `SpendStore` (in-memory default, Redis adapter included, or bring your own).
|
|
122
|
+
- **No-overshoot mode** when you supply `estimateUsage`; otherwise the cap is enforced on the next call after you cross it.
|
|
123
|
+
- `spendReport()` is async.
|
|
84
124
|
- Prices live in `PRICES` (USD per 1K tokens) — PRs to keep them current are welcome.
|
|
125
|
+
- Roadmap: streaming usage, more providers, a hosted dashboard. See ROADMAP.
|
|
126
|
+
|
|
127
|
+
## Migrating from 0.1
|
|
128
|
+
|
|
129
|
+
`spendReport()` is now `async` — add `await`. Everything else is backward compatible (no `store` = same in-process behavior).
|
|
85
130
|
|
|
86
131
|
## License
|
|
87
132
|
|
package/dist/guard.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SpendStore } from './store.js';
|
|
1
2
|
import type { GuardOptions, Usage } from './types.js';
|
|
2
3
|
/** 호출별 비용 이벤트 (대시보드/로그용). */
|
|
3
4
|
export interface SpendEvent {
|
|
@@ -22,6 +23,8 @@ export declare class BudgetExceededError extends Error {
|
|
|
22
23
|
capUsd: number;
|
|
23
24
|
constructor(project: string, spentUsd: number, capUsd: number);
|
|
24
25
|
}
|
|
26
|
+
/** 테스트 전용: 기본 메모리 저장소 초기화. */
|
|
27
|
+
export declare function __resetDefaultStore(): void;
|
|
25
28
|
type CreateArgs = {
|
|
26
29
|
model: string;
|
|
27
30
|
[k: string]: unknown;
|
|
@@ -41,8 +44,6 @@ export declare function guard<R extends object>(client: {
|
|
|
41
44
|
feature?: string;
|
|
42
45
|
}): Promise<R>;
|
|
43
46
|
};
|
|
44
|
-
/** 특정 프로젝트의 그날 기능별 비용 내역을 돌려준다. */
|
|
45
|
-
export declare function spendReport(project: string, day?: string): Record<string, number
|
|
46
|
-
/** 테스트 전용: 장부 초기화. */
|
|
47
|
-
export declare function _resetLedger(): void;
|
|
47
|
+
/** 특정 프로젝트의 그날 기능별 비용 내역을 돌려준다. (기본 저장소 또는 넘긴 store 기준) */
|
|
48
|
+
export declare function spendReport(project: string, day?: string, store?: SpendStore): Promise<Record<string, number>>;
|
|
48
49
|
export {};
|
package/dist/guard.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { cost } from './cost.js';
|
|
2
2
|
import { normalizeUsage } from './usage.js';
|
|
3
|
+
import { MemoryStore } from './store.js';
|
|
3
4
|
/** 캡 초과로 호출이 차단될 때 던지는 에러. */
|
|
4
5
|
export class BudgetExceededError extends Error {
|
|
5
6
|
project;
|
|
@@ -13,11 +14,14 @@ export class BudgetExceededError extends Error {
|
|
|
13
14
|
this.name = 'BudgetExceededError';
|
|
14
15
|
}
|
|
15
16
|
}
|
|
16
|
-
// 앱 전역 장부: "project|feature|YYYY-MM-DD" -> 누적 USD.
|
|
17
|
-
// (한 프로젝트의 캡은 호출 위치와 무관하게 합산되어야 하므로 모듈 전역)
|
|
18
|
-
const ledger = {};
|
|
19
17
|
const SEP = '|';
|
|
20
18
|
const TOTAL = '__total__';
|
|
19
|
+
// 기본 저장소: 프로세스 전역 단일 인스턴스 → 같은 프로세스 안에서는 project별 캡이 공유된다.
|
|
20
|
+
const defaultStore = new MemoryStore();
|
|
21
|
+
/** 테스트 전용: 기본 메모리 저장소 초기화. */
|
|
22
|
+
export function __resetDefaultStore() {
|
|
23
|
+
defaultStore.clear();
|
|
24
|
+
}
|
|
21
25
|
function dayKey(d) {
|
|
22
26
|
return d.toISOString().slice(0, 10);
|
|
23
27
|
}
|
|
@@ -32,15 +36,22 @@ function dayKey(d) {
|
|
|
32
36
|
export function guard(client, opts, internals = {}) {
|
|
33
37
|
const now = internals.now ?? (() => new Date());
|
|
34
38
|
const onCap = opts.onCap ?? 'block';
|
|
39
|
+
const store = opts.store ?? defaultStore;
|
|
35
40
|
const extract = internals.usageOf ?? ((res) => normalizeUsage(res.usage));
|
|
36
41
|
return {
|
|
37
42
|
async create(args, tags = {}) {
|
|
38
43
|
const day = dayKey(now());
|
|
39
44
|
const feature = tags.feature ?? 'default';
|
|
40
45
|
const totalKey = `${opts.project}${SEP}${TOTAL}${SEP}${day}`;
|
|
41
|
-
const spentToday =
|
|
42
|
-
// --- 하드
|
|
43
|
-
|
|
46
|
+
const spentToday = await store.get(totalKey);
|
|
47
|
+
// --- 하드 캡 (호출 전) ---
|
|
48
|
+
// estimateUsage가 있으면 "이 호출이 넘길지"를 미리 보고 그 호출을 차단(overshoot 방지).
|
|
49
|
+
// 없으면 이미 캡을 넘긴 경우 다음 호출을 차단.
|
|
50
|
+
const projected = opts.estimateUsage
|
|
51
|
+
? spentToday + cost(args.model, opts.estimateUsage(args))
|
|
52
|
+
: spentToday;
|
|
53
|
+
const over = opts.estimateUsage ? projected > opts.dailyCapUSD : spentToday >= opts.dailyCapUSD;
|
|
54
|
+
if (over) {
|
|
44
55
|
const err = new BudgetExceededError(opts.project, spentToday, opts.dailyCapUSD);
|
|
45
56
|
if (onCap === 'block')
|
|
46
57
|
throw err;
|
|
@@ -50,26 +61,19 @@ export function guard(client, opts, internals = {}) {
|
|
|
50
61
|
const res = await client.create(args);
|
|
51
62
|
// --- 비용 적립 + 기능별 귀속 ---
|
|
52
63
|
const usd = cost(args.model, extract(res));
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
internals.onSpend?.({
|
|
57
|
-
project: opts.project,
|
|
58
|
-
feature,
|
|
59
|
-
model: args.model,
|
|
60
|
-
usd,
|
|
61
|
-
dayTotalUsd: ledger[totalKey],
|
|
62
|
-
});
|
|
64
|
+
const dayTotalUsd = await store.add(totalKey, usd);
|
|
65
|
+
await store.add(`${opts.project}${SEP}${feature}${SEP}${day}`, usd);
|
|
66
|
+
internals.onSpend?.({ project: opts.project, feature, model: args.model, usd, dayTotalUsd });
|
|
63
67
|
return res;
|
|
64
68
|
},
|
|
65
69
|
};
|
|
66
70
|
}
|
|
67
|
-
/** 특정 프로젝트의 그날 기능별 비용 내역을 돌려준다. */
|
|
68
|
-
export function spendReport(project, day = dayKey(new Date())) {
|
|
71
|
+
/** 특정 프로젝트의 그날 기능별 비용 내역을 돌려준다. (기본 저장소 또는 넘긴 store 기준) */
|
|
72
|
+
export async function spendReport(project, day = dayKey(new Date()), store = defaultStore) {
|
|
69
73
|
const prefix = `${project}${SEP}`;
|
|
70
74
|
const suffix = `${SEP}${day}`;
|
|
71
75
|
const out = {};
|
|
72
|
-
for (const [k, v] of
|
|
76
|
+
for (const [k, v] of await store.entries(prefix)) {
|
|
73
77
|
if (k.startsWith(prefix) && k.endsWith(suffix)) {
|
|
74
78
|
const feature = k.slice(prefix.length, k.length - suffix.length);
|
|
75
79
|
if (feature !== TOTAL)
|
|
@@ -78,8 +82,3 @@ export function spendReport(project, day = dayKey(new Date())) {
|
|
|
78
82
|
}
|
|
79
83
|
return out;
|
|
80
84
|
}
|
|
81
|
-
/** 테스트 전용: 장부 초기화. */
|
|
82
|
-
export function _resetLedger() {
|
|
83
|
-
for (const k of Object.keys(ledger))
|
|
84
|
-
delete ledger[k];
|
|
85
|
-
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,5 +2,7 @@ export { guard, spendReport, BudgetExceededError } from './guard.js';
|
|
|
2
2
|
export { cost } from './cost.js';
|
|
3
3
|
export { normalizeUsage } from './usage.js';
|
|
4
4
|
export { PRICES } from './pricing.js';
|
|
5
|
+
export { MemoryStore, redisStore } from './store.js';
|
|
5
6
|
export type { Usage, GuardOptions } from './types.js';
|
|
6
7
|
export type { SpendEvent } from './guard.js';
|
|
8
|
+
export type { SpendStore, RedisLike } from './store.js';
|
package/dist/index.js
CHANGED
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 지출 누적 저장소. 기본은 프로세스 내 메모리(MemoryStore).
|
|
3
|
+
* 여러 인스턴스가 캡을 공유하려면 redisStore 등 공유 저장소를 넘긴다.
|
|
4
|
+
* 모든 메서드는 동기/비동기 둘 다 허용(guard가 await로 흡수).
|
|
5
|
+
*/
|
|
6
|
+
export interface SpendStore {
|
|
7
|
+
/** key의 누적값에 amount(USD)를 더하고 새 누적값을 돌려준다. 공유 저장소면 원자적이어야 함. */
|
|
8
|
+
add(key: string, amountUSD: number): number | Promise<number>;
|
|
9
|
+
/** key의 현재 누적값(없으면 0). */
|
|
10
|
+
get(key: string): number | Promise<number>;
|
|
11
|
+
/** prefix로 시작하는 모든 [key, total] 쌍 (spendReport용). */
|
|
12
|
+
entries(prefix: string): Array<[string, number]> | Promise<Array<[string, number]>>;
|
|
13
|
+
}
|
|
14
|
+
/** 기본 저장소: 프로세스 내 메모리. 단일 프로세스 앱/스크립트/에이전트에 적합. */
|
|
15
|
+
export declare class MemoryStore implements SpendStore {
|
|
16
|
+
private m;
|
|
17
|
+
add(key: string, amountUSD: number): number;
|
|
18
|
+
get(key: string): number;
|
|
19
|
+
entries(prefix: string): Array<[string, number]>;
|
|
20
|
+
/** 테스트/리셋용. */
|
|
21
|
+
clear(): void;
|
|
22
|
+
}
|
|
23
|
+
/** redisStore가 기대하는 최소 redis 클라이언트 형태 (node-redis v4 호환). */
|
|
24
|
+
export interface RedisLike {
|
|
25
|
+
incrByFloat(key: string, amount: number): Promise<string | number>;
|
|
26
|
+
get(key: string): Promise<string | null>;
|
|
27
|
+
expire(key: string, seconds: number): Promise<unknown>;
|
|
28
|
+
scan(cursor: number, opts: {
|
|
29
|
+
MATCH: string;
|
|
30
|
+
COUNT: number;
|
|
31
|
+
}): Promise<{
|
|
32
|
+
cursor: number | string;
|
|
33
|
+
keys: string[];
|
|
34
|
+
}>;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* 여러 인스턴스가 캡을 공유하는 Redis 백엔드 저장소. (BYO 클라이언트 — redis를 의존성으로 안 가짐)
|
|
38
|
+
* node-redis v4 기준: `redisStore(createClient())`. 키는 ttlSeconds(기본 2일) 후 만료되어 자연 일일 리셋.
|
|
39
|
+
*/
|
|
40
|
+
export declare function redisStore(client: RedisLike, opts?: {
|
|
41
|
+
ttlSeconds?: number;
|
|
42
|
+
keyPrefix?: string;
|
|
43
|
+
}): SpendStore;
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** 기본 저장소: 프로세스 내 메모리. 단일 프로세스 앱/스크립트/에이전트에 적합. */
|
|
2
|
+
export class MemoryStore {
|
|
3
|
+
m = new Map();
|
|
4
|
+
add(key, amountUSD) {
|
|
5
|
+
const n = (this.m.get(key) ?? 0) + amountUSD;
|
|
6
|
+
this.m.set(key, n);
|
|
7
|
+
return n;
|
|
8
|
+
}
|
|
9
|
+
get(key) {
|
|
10
|
+
return this.m.get(key) ?? 0;
|
|
11
|
+
}
|
|
12
|
+
entries(prefix) {
|
|
13
|
+
const out = [];
|
|
14
|
+
for (const [k, v] of this.m)
|
|
15
|
+
if (k.startsWith(prefix))
|
|
16
|
+
out.push([k, v]);
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
/** 테스트/리셋용. */
|
|
20
|
+
clear() {
|
|
21
|
+
this.m.clear();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* 여러 인스턴스가 캡을 공유하는 Redis 백엔드 저장소. (BYO 클라이언트 — redis를 의존성으로 안 가짐)
|
|
26
|
+
* node-redis v4 기준: `redisStore(createClient())`. 키는 ttlSeconds(기본 2일) 후 만료되어 자연 일일 리셋.
|
|
27
|
+
*/
|
|
28
|
+
export function redisStore(client, opts = {}) {
|
|
29
|
+
const ttl = opts.ttlSeconds ?? 172800; // 2일
|
|
30
|
+
const pre = opts.keyPrefix ?? 'bg:';
|
|
31
|
+
const num = (v) => v == null ? 0 : typeof v === 'number' ? v : parseFloat(v);
|
|
32
|
+
return {
|
|
33
|
+
async add(key, amountUSD) {
|
|
34
|
+
const k = pre + key;
|
|
35
|
+
const n = await client.incrByFloat(k, amountUSD);
|
|
36
|
+
await client.expire(k, ttl);
|
|
37
|
+
return num(n);
|
|
38
|
+
},
|
|
39
|
+
async get(key) {
|
|
40
|
+
return num(await client.get(pre + key));
|
|
41
|
+
},
|
|
42
|
+
async entries(prefix) {
|
|
43
|
+
const out = [];
|
|
44
|
+
let cursor = 0;
|
|
45
|
+
do {
|
|
46
|
+
const res = await client.scan(cursor, { MATCH: pre + prefix + '*', COUNT: 200 });
|
|
47
|
+
cursor = Number(res.cursor);
|
|
48
|
+
for (const fullKey of res.keys) {
|
|
49
|
+
const v = await client.get(fullKey);
|
|
50
|
+
if (v != null)
|
|
51
|
+
out.push([fullKey.slice(pre.length), num(v)]);
|
|
52
|
+
}
|
|
53
|
+
} while (cursor !== 0);
|
|
54
|
+
return out;
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SpendStore } from './store.js';
|
|
1
2
|
/** 한 번의 LLM 호출이 쓴 토큰 수. 제공자가 응답의 usage로 알려준다. */
|
|
2
3
|
export interface Usage {
|
|
3
4
|
input: number;
|
|
@@ -7,8 +8,21 @@ export interface Usage {
|
|
|
7
8
|
export interface GuardOptions {
|
|
8
9
|
/** 비용을 묶는 단위(예: 'agent-worker'). */
|
|
9
10
|
project: string;
|
|
10
|
-
/** 하루 하드 캡(USD). 초과하면
|
|
11
|
+
/** 하루 하드 캡(USD). 초과하면 호출을 막는다. */
|
|
11
12
|
dailyCapUSD: number;
|
|
12
13
|
/** 캡 초과 시 동작. 기본 'block'(throw) / 'warn'(경고만). */
|
|
13
14
|
onCap?: 'block' | 'warn';
|
|
15
|
+
/**
|
|
16
|
+
* 지출 저장소. 기본은 프로세스 공유 MemoryStore.
|
|
17
|
+
* 여러 인스턴스가 캡을 공유하려면 redisStore 등을 넘긴다.
|
|
18
|
+
*/
|
|
19
|
+
store?: SpendStore;
|
|
20
|
+
/**
|
|
21
|
+
* (선택) 호출 전 usage 추정기. 주면 "이 호출이 캡을 넘길지"를 호출 전에 판단해
|
|
22
|
+
* 넘기는 호출 자체를 차단(overshoot 방지). 없으면 캡 초과 후 '다음' 호출을 차단.
|
|
23
|
+
*/
|
|
24
|
+
estimateUsage?: (args: {
|
|
25
|
+
model: string;
|
|
26
|
+
[k: string]: unknown;
|
|
27
|
+
}) => Usage;
|
|
14
28
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "budget-guard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "A circuit breaker for your LLM API bill — hard budget caps + per-feature cost attribution for the OpenAI & Anthropic APIs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
".": {
|
|
10
10
|
"types": "./dist/index.d.ts",
|
|
11
11
|
"import": "./dist/index.js"
|
|
12
|
-
}
|
|
12
|
+
},
|
|
13
|
+
"./package.json": "./package.json"
|
|
13
14
|
},
|
|
14
15
|
"files": ["dist", "README.md"],
|
|
15
16
|
"scripts": {
|