budget-guard 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kimbeomgyu
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 ADDED
@@ -0,0 +1,88 @@
1
+ # budget-guard
2
+
3
+ **A circuit breaker for your LLM API bill.** One wrap, set a hard daily cap — runaway retry loops get blocked *before* they bill you. Plus per-feature cost attribution so you know what actually costs what.
4
+
5
+ > Built for indie devs shipping on the OpenAI / Anthropic APIs who've seen "a $40 bill from a $5 task." Drop-in: your calls still go straight to the provider — `budget-guard` just counts and caps.
6
+
7
+ Free & open source (MIT). A hosted dashboard with cross-project spend + alerts is planned — but the SDK is, and stays, free.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm i budget-guard
13
+ ```
14
+
15
+ ## Use it (OpenAI)
16
+
17
+ ```ts
18
+ import OpenAI from 'openai';
19
+ import { guard } from 'budget-guard';
20
+
21
+ const openai = new OpenAI();
22
+ const ai = guard(openai.chat.completions, { project: 'my-app', dailyCapUSD: 50 });
23
+
24
+ // use it exactly like before — just add an optional feature tag
25
+ const res = await ai.create(
26
+ { model: 'gpt-4o', messages: [{ role: 'user', content: 'hi' }] },
27
+ { feature: 'chat' },
28
+ );
29
+ ```
30
+
31
+ If today's spend for `my-app` is already past `$50`, the **next call throws `BudgetExceededError` before it bills**. No more 3am surprise invoices.
32
+
33
+ ## Use it (Anthropic)
34
+
35
+ ```ts
36
+ import Anthropic from '@anthropic-ai/sdk';
37
+ import { guard } from 'budget-guard';
38
+
39
+ const anthropic = new Anthropic();
40
+ const ai = guard(anthropic.messages, { project: 'my-app', dailyCapUSD: 50 });
41
+
42
+ await ai.create(
43
+ { model: 'claude-opus-4', max_tokens: 1024, messages: [{ role: 'user', content: 'hi' }] },
44
+ { feature: 'summarize' },
45
+ );
46
+ ```
47
+
48
+ `budget-guard` auto-detects OpenAI (`prompt_tokens`/`completion_tokens`) and Anthropic (`input_tokens`/`output_tokens`) usage shapes. For anything else, pass your own extractor:
49
+
50
+ ```ts
51
+ guard(client, opts, { usageOf: (res) => ({ input: res.in, output: res.out }) });
52
+ ```
53
+
54
+ ## Know what costs what
55
+
56
+ ```ts
57
+ import { spendReport } from 'budget-guard';
58
+
59
+ spendReport('my-app');
60
+ // → { chat: 2.41, summarize: 0.88 } (today, in USD)
61
+ ```
62
+
63
+ ## Options
64
+
65
+ ```ts
66
+ guard(client, {
67
+ project: 'my-app', // groups spend & shares one cap
68
+ dailyCapUSD: 50, // hard cap per day
69
+ onCap: 'block', // 'block' (throw) | 'warn' (log only). default 'block'
70
+ });
71
+ ```
72
+
73
+ ## Warn instead of block
74
+
75
+ ```ts
76
+ const ai = guard(openai.chat.completions, { project: 'my-app', dailyCapUSD: 50, onCap: 'warn' });
77
+ // over cap → logs a warning, still calls. Good for easing in.
78
+ ```
79
+
80
+ ## Notes (v0.1)
81
+
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.
84
+ - Prices live in `PRICES` (USD per 1K tokens) — PRs to keep them current are welcome.
85
+
86
+ ## License
87
+
88
+ MIT
package/dist/cost.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ import type { Usage } from './types';
2
+ /** 토큰 사용량을 가격표 기준 USD 비용으로 환산한다. */
3
+ export declare function cost(model: string, usage: Usage): number;
package/dist/cost.js ADDED
@@ -0,0 +1,8 @@
1
+ import { PRICES } from './pricing';
2
+ /** 토큰 사용량을 가격표 기준 USD 비용으로 환산한다. */
3
+ export function cost(model, usage) {
4
+ const p = PRICES[model];
5
+ if (!p)
6
+ throw new Error(`Unknown model: ${model}. Add it to PRICES in pricing.ts`);
7
+ return (usage.input / 1000) * p.in + (usage.output / 1000) * p.out;
8
+ }
@@ -0,0 +1,48 @@
1
+ import type { GuardOptions, Usage } from './types';
2
+ /** 호출별 비용 이벤트 (대시보드/로그용). */
3
+ export interface SpendEvent {
4
+ project: string;
5
+ feature: string;
6
+ model: string;
7
+ usd: number;
8
+ dayTotalUsd: number;
9
+ }
10
+ interface GuardInternals<R> {
11
+ /** 테스트용 주입 시계. 기본 실제 시각. */
12
+ now?: () => Date;
13
+ /** 비용 발생 시 콜백 (로깅/대시보드 전송). */
14
+ onSpend?: (e: SpendEvent) => void;
15
+ /** 제공자 응답에서 토큰 usage를 직접 뽑는 추출기 (자동 인식 안 될 때). */
16
+ usageOf?: (res: R) => Usage;
17
+ }
18
+ /** 캡 초과로 호출이 차단될 때 던지는 에러. */
19
+ export declare class BudgetExceededError extends Error {
20
+ project: string;
21
+ spentUsd: number;
22
+ capUsd: number;
23
+ constructor(project: string, spentUsd: number, capUsd: number);
24
+ }
25
+ type CreateArgs = {
26
+ model: string;
27
+ [k: string]: unknown;
28
+ };
29
+ /**
30
+ * LLM 클라이언트를 감싸 (1) 하루 하드 캡으로 폭주를 차단하고
31
+ * (2) project/feature별로 비용을 귀속한다. 실제 호출은 제공자로 그대로 나간다.
32
+ *
33
+ * @example
34
+ * const ai = guard(openai.chat.completions, { project: 'app', dailyCapUSD: 50 });
35
+ * await ai.create({ model: 'gpt-4o', messages }, { feature: 'summarize' });
36
+ */
37
+ export declare function guard<R extends object>(client: {
38
+ create(args: CreateArgs): Promise<R>;
39
+ }, opts: GuardOptions, internals?: GuardInternals<R>): {
40
+ create(args: CreateArgs, tags?: {
41
+ feature?: string;
42
+ }): Promise<R>;
43
+ };
44
+ /** 특정 프로젝트의 그날 기능별 비용 내역을 돌려준다. */
45
+ export declare function spendReport(project: string, day?: string): Record<string, number>;
46
+ /** 테스트 전용: 장부 초기화. */
47
+ export declare function _resetLedger(): void;
48
+ export {};
package/dist/guard.js ADDED
@@ -0,0 +1,85 @@
1
+ import { cost } from './cost';
2
+ import { normalizeUsage } from './usage';
3
+ /** 캡 초과로 호출이 차단될 때 던지는 에러. */
4
+ export class BudgetExceededError extends Error {
5
+ project;
6
+ spentUsd;
7
+ capUsd;
8
+ constructor(project, spentUsd, capUsd) {
9
+ super(`🛡 Budget cap hit for "${project}": $${spentUsd.toFixed(2)} / $${capUsd} — call blocked`);
10
+ this.project = project;
11
+ this.spentUsd = spentUsd;
12
+ this.capUsd = capUsd;
13
+ this.name = 'BudgetExceededError';
14
+ }
15
+ }
16
+ // 앱 전역 장부: "project|feature|YYYY-MM-DD" -> 누적 USD.
17
+ // (한 프로젝트의 캡은 호출 위치와 무관하게 합산되어야 하므로 모듈 전역)
18
+ const ledger = {};
19
+ const SEP = '|';
20
+ const TOTAL = '__total__';
21
+ function dayKey(d) {
22
+ return d.toISOString().slice(0, 10);
23
+ }
24
+ /**
25
+ * LLM 클라이언트를 감싸 (1) 하루 하드 캡으로 폭주를 차단하고
26
+ * (2) project/feature별로 비용을 귀속한다. 실제 호출은 제공자로 그대로 나간다.
27
+ *
28
+ * @example
29
+ * const ai = guard(openai.chat.completions, { project: 'app', dailyCapUSD: 50 });
30
+ * await ai.create({ model: 'gpt-4o', messages }, { feature: 'summarize' });
31
+ */
32
+ export function guard(client, opts, internals = {}) {
33
+ const now = internals.now ?? (() => new Date());
34
+ const onCap = opts.onCap ?? 'block';
35
+ const extract = internals.usageOf ?? ((res) => normalizeUsage(res.usage));
36
+ return {
37
+ async create(args, tags = {}) {
38
+ const day = dayKey(now());
39
+ const feature = tags.feature ?? 'default';
40
+ const totalKey = `${opts.project}${SEP}${TOTAL}${SEP}${day}`;
41
+ const spentToday = ledger[totalKey] ?? 0;
42
+ // --- 하드 캡: 돈 나가는 호출 "전에" 차단 ---
43
+ if (spentToday >= opts.dailyCapUSD) {
44
+ const err = new BudgetExceededError(opts.project, spentToday, opts.dailyCapUSD);
45
+ if (onCap === 'block')
46
+ throw err;
47
+ console.warn(err.message);
48
+ }
49
+ // --- 진짜 호출은 제공자에게 그대로 ---
50
+ const res = await client.create(args);
51
+ // --- 비용 적립 + 기능별 귀속 ---
52
+ 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
+ });
63
+ return res;
64
+ },
65
+ };
66
+ }
67
+ /** 특정 프로젝트의 그날 기능별 비용 내역을 돌려준다. */
68
+ export function spendReport(project, day = dayKey(new Date())) {
69
+ const prefix = `${project}${SEP}`;
70
+ const suffix = `${SEP}${day}`;
71
+ const out = {};
72
+ for (const [k, v] of Object.entries(ledger)) {
73
+ if (k.startsWith(prefix) && k.endsWith(suffix)) {
74
+ const feature = k.slice(prefix.length, k.length - suffix.length);
75
+ if (feature !== TOTAL)
76
+ out[feature] = v;
77
+ }
78
+ }
79
+ return out;
80
+ }
81
+ /** 테스트 전용: 장부 초기화. */
82
+ export function _resetLedger() {
83
+ for (const k of Object.keys(ledger))
84
+ delete ledger[k];
85
+ }
@@ -0,0 +1,6 @@
1
+ export { guard, spendReport, BudgetExceededError } from './guard';
2
+ export { cost } from './cost';
3
+ export { normalizeUsage } from './usage';
4
+ export { PRICES } from './pricing';
5
+ export type { Usage, GuardOptions } from './types';
6
+ export type { SpendEvent } from './guard';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { guard, spendReport, BudgetExceededError } from './guard';
2
+ export { cost } from './cost';
3
+ export { normalizeUsage } from './usage';
4
+ export { PRICES } from './pricing';
@@ -0,0 +1,5 @@
1
+ /** 모델별 1K 토큰당 USD 단가 [input, output]. 제공자 가격 변경 시 갱신. */
2
+ export declare const PRICES: Record<string, {
3
+ in: number;
4
+ out: number;
5
+ }>;
@@ -0,0 +1,6 @@
1
+ /** 모델별 1K 토큰당 USD 단가 [input, output]. 제공자 가격 변경 시 갱신. */
2
+ export const PRICES = {
3
+ 'gpt-4o': { in: 0.0025, out: 0.01 },
4
+ 'gpt-4o-mini': { in: 0.00015, out: 0.0006 },
5
+ 'claude-opus-4': { in: 0.015, out: 0.075 },
6
+ };
@@ -0,0 +1,14 @@
1
+ /** 한 번의 LLM 호출이 쓴 토큰 수. 제공자가 응답의 usage로 알려준다. */
2
+ export interface Usage {
3
+ input: number;
4
+ output: number;
5
+ }
6
+ /** guard()에 넘기는 설정. */
7
+ export interface GuardOptions {
8
+ /** 비용을 묶는 단위(예: 'agent-worker'). */
9
+ project: string;
10
+ /** 하루 하드 캡(USD). 초과하면 다음 호출을 막는다. */
11
+ dailyCapUSD: number;
12
+ /** 캡 초과 시 동작. 기본 'block'(throw) / 'warn'(경고만). */
13
+ onCap?: 'block' | 'warn';
14
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,9 @@
1
+ import type { Usage } from './types';
2
+ /**
3
+ * 제공자별 usage 형태를 {input, output} 토큰으로 정규화한다.
4
+ * - 우리 형태: { input, output }
5
+ * - OpenAI: { prompt_tokens, completion_tokens }
6
+ * - Anthropic: { input_tokens, output_tokens }
7
+ * 인식 못 하면 guard()에 usageOf 추출기를 넘기라고 안내.
8
+ */
9
+ export declare function normalizeUsage(raw: unknown): Usage;
package/dist/usage.js ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * 제공자별 usage 형태를 {input, output} 토큰으로 정규화한다.
3
+ * - 우리 형태: { input, output }
4
+ * - OpenAI: { prompt_tokens, completion_tokens }
5
+ * - Anthropic: { input_tokens, output_tokens }
6
+ * 인식 못 하면 guard()에 usageOf 추출기를 넘기라고 안내.
7
+ */
8
+ export function normalizeUsage(raw) {
9
+ const u = raw;
10
+ if (!u) {
11
+ throw new Error('No usage on response. Pass a usageOf() extractor to guard().');
12
+ }
13
+ if (typeof u.input === 'number' && typeof u.output === 'number') {
14
+ return { input: u.input, output: u.output };
15
+ }
16
+ if (typeof u.prompt_tokens === 'number') {
17
+ return { input: u.prompt_tokens, output: u.completion_tokens ?? 0 };
18
+ }
19
+ if (typeof u.input_tokens === 'number') {
20
+ return { input: u.input_tokens, output: u.output_tokens ?? 0 };
21
+ }
22
+ throw new Error('Unrecognized usage shape. Pass a usageOf() extractor to guard().');
23
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "budget-guard",
3
+ "version": "0.1.0",
4
+ "description": "A circuit breaker for your LLM API bill — hard budget caps + per-feature cost attribution for the OpenAI & Anthropic APIs.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": ["dist", "README.md"],
15
+ "scripts": {
16
+ "test": "vitest run",
17
+ "test:watch": "vitest",
18
+ "build": "tsc",
19
+ "prepublishOnly": "npm run build"
20
+ },
21
+ "keywords": ["openai", "anthropic", "llm", "cost", "budget", "tokens", "ai", "rate-limit"],
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/kimbeomgyu/budget-guard.git"
25
+ },
26
+ "bugs": {
27
+ "url": "https://github.com/kimbeomgyu/budget-guard/issues"
28
+ },
29
+ "homepage": "https://github.com/kimbeomgyu/budget-guard#readme",
30
+ "author": "kimbeomgyu",
31
+ "license": "MIT",
32
+ "devDependencies": {
33
+ "typescript": "^5.5.0",
34
+ "vitest": "^2.1.0"
35
+ }
36
+ }