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 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.1)
119
+ ## Notes (v0.2)
81
120
 
82
- - Caps are accounted **after each call** and enforced on the **next** one (no pre-call token estimation yet).
83
- - The ledger is in-memory per process. Persistence + a hosted dashboard are on the roadmap.
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 = ledger[totalKey] ?? 0;
42
- // --- 하드 캡: 돈 나가는 호출 "전에" 차단 ---
43
- if (spentToday >= opts.dailyCapUSD) {
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
- ledger[totalKey] = spentToday + usd;
54
- const featKey = `${opts.project}${SEP}${feature}${SEP}${day}`;
55
- ledger[featKey] = (ledger[featKey] ?? 0) + usd;
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 Object.entries(ledger)) {
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
@@ -2,3 +2,4 @@ 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';
@@ -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.1.1",
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": {