claude-token-saver 3.34.0 → 3.35.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/bin/cli.js CHANGED
@@ -87,7 +87,7 @@ function readUpdateChip() {
87
87
  const KNOWN_SUBCOMMANDS = new Set([
88
88
  'last', 'brief', 'history', 'handoff', 'install', 'uninstall', 'mode', 'korean',
89
89
  'doc2md', 'harness', 'route-scan', 'compact-window', 'update-check', 'upgrade',
90
- 'seed',
90
+ 'seed', 'litellm-budget',
91
91
  ]);
92
92
 
93
93
  const USAGE = `claude-token-saver — Claude Code token usage, cache health, and model routing
@@ -274,6 +274,32 @@ async function main() {
274
274
  });
275
275
  }
276
276
 
277
+ // Subcommand: litellm-budget · LiteLLM 게이트웨이 키의 max_budget/spend 조회.
278
+ // claude-token-saver litellm-budget # 캐시된 예산 상태 출력
279
+ // claude-token-saver litellm-budget --refresh # 지금 프록시에 물어봄 (detached 자식이 사용)
280
+ if (args[0] === 'litellm-budget') {
281
+ const { gatewayEnv, readBudgetState, refreshBudgetState } = await import('../src/litellm-budget.js');
282
+ const quiet = hasFlag('--quiet');
283
+ if (hasFlag('--refresh')) {
284
+ try {
285
+ const next = await refreshBudgetState();
286
+ if (!quiet) console.log(JSON.stringify(next, null, 2));
287
+ } catch (e) {
288
+ debug('litellm-budget:refresh', e);
289
+ if (!quiet) console.error(`litellm-budget refresh failed: ${e.message}`);
290
+ process.exitCode = 1;
291
+ }
292
+ return;
293
+ }
294
+ const gw = gatewayEnv();
295
+ if (!gw) {
296
+ console.log('게이트웨이가 감지되지 않았습니다 (ANTHROPIC_BASE_URL + 키 필요).');
297
+ return;
298
+ }
299
+ console.log(JSON.stringify(readBudgetState(), null, 2));
300
+ return;
301
+ }
302
+
277
303
  // Subcommand: upgrade — run the install command that matches how this copy
278
304
  // got here, then confirm the new version.
279
305
  // claude-token-saver upgrade # install the latest release
@@ -493,6 +519,21 @@ async function main() {
493
519
  debug('caps-cache:persist', e);
494
520
  }
495
521
  }
522
+ // LiteLLM 게이트웨이(Bedrock 등): stdin에 rate_limits가 아예 오지 않으므로
523
+ // 키의 max_budget/spend 를 cap 게이지로 대신 보여 준다. 조회는 캐시만 읽고,
524
+ // 갱신은 detached 자식에게 맡겨 렌더가 네트워크를 기다리지 않게 한다.
525
+ if (isStatusline) {
526
+ try {
527
+ const { budgetWindow, maybeSpawnBudgetCheck } = await import('../src/litellm-budget.js');
528
+ maybeSpawnBudgetCheck();
529
+ if (!caps || !Array.isArray(caps.windows) || caps.windows.length === 0) {
530
+ const bw = budgetWindow();
531
+ if (bw) caps = { windows: [bw] };
532
+ }
533
+ } catch (e) {
534
+ debug('litellm-budget:window', e);
535
+ }
536
+ }
496
537
  if (!isStatusline && (!caps || !model)) {
497
538
  try {
498
539
  const { loadRecentSnapshot } = await import('../src/caps-cache.js');
@@ -575,6 +616,23 @@ async function main() {
575
616
  }
576
617
  const lastActivity = Math.max(otherLastActivity, currentSessionLastUser);
577
618
 
619
+ // 이번 달 1일 00시 이후 지출 추정치. 통계선 기본 창(30d)이 월초를 덮으면
620
+ // 이미 파싱한 세션을 재사용하고, 사용자가 창을 줄여 둔 경우(mode 1d 등)에만
621
+ // 월초까지 다시 파싱한다. 파싱 결과는 세션 캐시가 받아 주므로 싸다.
622
+ let monthSpendInfo = null;
623
+ if (isStatusline) {
624
+ try {
625
+ const { monthSpend, monthStartMs } = await import('../src/month-spend.js');
626
+ const daysNeeded = (Date.now() - monthStartMs()) / 86400000;
627
+ const monthSessions = days >= daysNeeded
628
+ ? sessions
629
+ : await parseAllSessions({ days: Math.max(1, Math.ceil(daysNeeded)), projectFilter });
630
+ monthSpendInfo = monthSpend(monthSessions);
631
+ } catch (e) {
632
+ debug('month-spend', e);
633
+ }
634
+ }
635
+
578
636
  // What delegation has measurably saved, read straight from the registry
579
637
  // route-scan maintains. A lookup, never a scan: the statusline re-renders
580
638
  // every few seconds and a scan parses tens of MB of transcripts.
@@ -636,6 +694,7 @@ async function main() {
636
694
  // Cached-only; the background refresh it may trigger lands on a later render.
637
695
  update: format === 'statusline' ? readUpdateChip() : null,
638
696
  lastActivity,
697
+ monthSpend: monthSpendInfo,
639
698
  spikeReport,
640
699
  contextWindow,
641
700
  ctxLive,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-token-saver",
3
- "version": "3.34.0",
3
+ "version": "3.35.0",
4
4
  "description": "Route the easy work your expensive Claude model keeps repeating down to haiku/sonnet — post-hoc session analysis, no realtime router, no extra LLM calls.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -380,6 +380,25 @@ export function formatReport(data, { color = true, verbose = false, timer = true
380
380
  doc2mdSeg = `${c(GRAY)}${label} ${doc2mdDocs} docs${c(RESET)}`;
381
381
  }
382
382
 
383
+ // 이번 달 지출 세그먼트. 달력 월(1일 00시 기준) 지출 추정치라서 cap 이 없는
384
+ // 게이트웨이 사용자에게도 항상 의미가 있다. 정보성 지표이므로 회색으로,
385
+ // 게이지들 뒤에 조용히 놓는다.
386
+ // icon: "💵 Sep $42.1"
387
+ // text: "Sep spend $42.1"
388
+ // verbose: "Sep spend $42.1 (since Sep 1)"
389
+ const month = data.monthSpend;
390
+ let monthSeg = null;
391
+ if (month && Number(month.usd) > 0) {
392
+ const amt = formatMoney(Number(month.usd));
393
+ if (isIcon) {
394
+ monthSeg = `${c(GRAY)}💵 ${month.label} ${amt}${c(RESET)}`;
395
+ } else if (verbose) {
396
+ monthSeg = `${c(GRAY)}${month.label} spend ${amt} (since ${month.label} 1)${c(RESET)}`;
397
+ } else {
398
+ monthSeg = `${c(GRAY)}${month.label} spend ${amt}${c(RESET)}`;
399
+ }
400
+ }
401
+
383
402
  // Routing-savings headline line (multi-line layout). The lifetime sum from
384
403
  // the delegation ledger — the number the whole tool exists to grow, so it
385
404
  // gets line 1 to itself while the diagnostics move to line 2.
@@ -615,15 +634,21 @@ export function formatReport(data, { color = true, verbose = false, timer = true
615
634
  // Icon mode renders an inline ▰▱ gauge instead of the literal "cap used" —
616
635
  // a glance at the bar conveys urgency faster than parsing a percent number,
617
636
  // and the gauge stays the same width as the percent climbs.
637
+ // LiteLLM 예산 윈도우는 퍼센트만으로는 감이 안 오므로(예산 크기를 모름)
638
+ // 금액을 함께 보여 준다: `🔑 budget ▰▱ 34% $34/$100`.
639
+ const money =
640
+ Number.isFinite(info.maxBudget) && info.maxBudget > 0
641
+ ? ` ${formatMoney(Number(info.spend) || 0)}/${formatMoney(info.maxBudget)}`
642
+ : '';
618
643
  if (isIcon) {
619
644
  const labelPart = labels.usageLabel ? `${labels.usageLabel} ` : '';
620
645
  const bar = gaugeBar(pct);
621
- return `${c(tone)}${labels.icon} ${labelPart}${bar} ${pct}%${tail}${c(RESET)}`;
646
+ return `${c(tone)}${labels.icon} ${labelPart}${bar} ${pct}%${money}${tail}${c(RESET)}`;
622
647
  }
623
648
  if (verbose) {
624
- return `${c(tone)}${labels.short} cap ${pct}% used${tail}${c(RESET)}`;
649
+ return `${c(tone)}${labels.short} cap ${pct}% used${money}${tail}${c(RESET)}`;
625
650
  }
626
- return `${c(tone)}${labels.short} cap ${pct}%${tail}${c(RESET)}`;
651
+ return `${c(tone)}${labels.short} cap ${pct}%${money}${tail}${c(RESET)}`;
627
652
  }
628
653
  // Color tone: green <70%, yellow 70-89%, red 90+% (a 90+% window only
629
654
  // renders here when a *different* window won the cap-warn chip slot).
@@ -695,6 +720,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
695
720
  for (const { key, seg } of usageSegs) {
696
721
  if (usageWant(key)) segs.push(seg);
697
722
  }
723
+ if (monthSeg && want('month')) segs.push(monthSeg);
698
724
  if (ctxSeg && want('ctx')) segs.push(ctxSeg);
699
725
  // Cache saved is the "lifetime brag" stat — useful but not actionable, so
700
726
  // it sits near the tail. The period label closes the line as a quiet
@@ -0,0 +1,223 @@
1
+ /**
2
+ * litellm-budget: LiteLLM 게이트웨이 사용자에게 예산 정보를 조회해 줍니다.
3
+ *
4
+ * Bedrock 등을 LiteLLM 프록시로 쓰는 환경에는 Claude Code stdin의
5
+ * rate_limits(5h/7d cap)가 아예 오지 않습니다. 대신 LiteLLM은 키별
6
+ * max_budget / spend 를 관리하므로, 그 값을 가져와 cap 게이지가 놓이던 지점에
7
+ * 예산 게이지를 보여 줍니다.
8
+ *
9
+ * 네트워크 호출은 update-check와 같은 모양으로 처리합니다: 렌더 경로는
10
+ * 캐시 파일만 읽고, 캐시가 오래되면 detached 자식 프로세스를 띄워
11
+ * 다음 렌더를 위해 갱신합니다. 렌더가 네트워크를 기다리는 일은 없습니다.
12
+ *
13
+ * 감지 조건: ANTHROPIC_BASE_URL 이 설정돼 있고(공식 API가 아닌 게이트웨이),
14
+ * 키(ANTHROPIC_AUTH_TOKEN 또는 ANTHROPIC_API_KEY)가 있을 때만 동작합니다.
15
+ * 엔드포인트는 LiteLLM의 `GET {base}/key/info` 이며, 호출한 키 자신의 정보를
16
+ * 돌려줍니다. 응답의 info.max_budget / info.spend / info.budget_reset_at 을
17
+ * 사용합니다. max_budget 이 null 이면(무제한 키) 게이지를 만들지 않습니다.
18
+ */
19
+
20
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
21
+ import { spawn } from 'node:child_process';
22
+ import { join } from 'node:path';
23
+ import { userDataDir } from './paths.js';
24
+ import { cliEntryPath } from './update-check.js';
25
+ import { debug } from './debug.js';
26
+
27
+ // 예산은 분 단위로 변하지 않습니다. 5분이면 게이지 용도로 충분히 신선하고,
28
+ // 통계선 렌더(수 초 간격)가 프록시를 두들기지 않습니다.
29
+ const CHECK_INTERVAL_MS = 5 * 60 * 1000;
30
+ const FETCH_TIMEOUT_MS = 5000;
31
+
32
+ export function budgetStatePath() {
33
+ return join(userDataDir(), 'litellm-budget.json');
34
+ }
35
+
36
+ /** 게이트웨이 환경 정보. 감지되지 않으면 null. */
37
+ export function gatewayEnv(env = process.env) {
38
+ const base = (env.ANTHROPIC_BASE_URL || '').trim().replace(/\/+$/, '');
39
+ if (!base) return null;
40
+ // 공식 엔드포인트를 그대로 가리키면 게이트웨이가 아닙니다.
41
+ if (/^https?:\/\/api\.anthropic\.com/i.test(base)) return null;
42
+ const key = (env.ANTHROPIC_AUTH_TOKEN || env.ANTHROPIC_API_KEY || '').trim();
43
+ if (!key) return null;
44
+ return { base, key };
45
+ }
46
+
47
+ export function readBudgetState() {
48
+ try {
49
+ const s = JSON.parse(readFileSync(budgetStatePath(), 'utf8'));
50
+ return s && typeof s === 'object' ? s : {};
51
+ } catch {
52
+ return {};
53
+ }
54
+ }
55
+
56
+ function writeBudgetState(next) {
57
+ const dir = userDataDir();
58
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
59
+ writeFileSync(budgetStatePath(), JSON.stringify(next, null, 2) + '\n');
60
+ }
61
+
62
+ /**
63
+ * 렌더 경로가 쓰는 읽기 전용 접근자. 캐시된 답이 있으면
64
+ * rate_limits 윈도우와 같은 모양의 객체를 돌려줍니다.
65
+ *
66
+ * @returns {{key:string, usedPct:number, resetsAt:number|null,
67
+ * spend:number, maxBudget:number}|null}
68
+ */
69
+ export function budgetWindow(env = process.env) {
70
+ const gw = gatewayEnv(env);
71
+ if (!gw) return null;
72
+ const s = readBudgetState();
73
+ // 다른 프록시의 캐시를 재사용하지 않도록 base 단위로 격리합니다.
74
+ if (s.base !== gw.base) return null;
75
+ const max = Number(s.maxBudget);
76
+ const spend = Number(s.spend);
77
+ if (!Number.isFinite(max) || max <= 0 || !Number.isFinite(spend)) return null;
78
+ // 캐시에는 ms 로 저장하지만, rate_limits 윈도우의 resets_at 규약은
79
+ // epoch 초 단위라서 여기서 초로 맞춰 내보낸다 (formatResetClock 입력 규약).
80
+ const resetMs = Number(s.budgetResetAt) || null;
81
+ return {
82
+ key: 'litellm_budget',
83
+ usedPct: Math.min(100, Math.max(0, (spend / max) * 100)),
84
+ resetsAt: resetMs ? Math.round(resetMs / 1000) : null,
85
+ spend,
86
+ maxBudget: max,
87
+ };
88
+ }
89
+
90
+ /** 캐시가 오래됐으면 detached 자식으로 갱신을 예약합니다. 즉시 반환. */
91
+ export function maybeSpawnBudgetCheck(env = process.env) {
92
+ const gw = gatewayEnv(env);
93
+ if (!gw) return false;
94
+ const s = readBudgetState();
95
+ const age = Date.now() - (Number(s.checkedAt) || 0);
96
+ if (s.base === gw.base && age < CHECK_INTERVAL_MS) return false;
97
+ // 오프라인/오류 시 렌더마다 자식을 다시 띄우지 않도록 시도 시각을 먼저 기록합니다.
98
+ try {
99
+ writeBudgetState({ ...s, base: gw.base, checkedAt: Date.now() });
100
+ } catch (e) {
101
+ debug('litellm-budget:stamp', e);
102
+ return false;
103
+ }
104
+ try {
105
+ spawn(process.execPath, [cliEntryPath(), 'litellm-budget', '--refresh', '--quiet'], {
106
+ detached: true,
107
+ stdio: 'ignore',
108
+ windowsHide: true,
109
+ }).unref();
110
+ return true;
111
+ } catch (e) {
112
+ debug('litellm-budget:spawn', e);
113
+ return false;
114
+ }
115
+ }
116
+
117
+ function numOrNull(v) {
118
+ const n = Number(v);
119
+ return v !== null && v !== undefined && Number.isFinite(n) ? n : null;
120
+ }
121
+
122
+ function parseResetMs(v) {
123
+ if (!v) return null;
124
+ const ms = Date.parse(v);
125
+ return Number.isFinite(ms) ? ms : null;
126
+ }
127
+
128
+ /**
129
+ * /key/info 와 /user/info 응답에서 예산 출처를 고릅니다 (도커 LiteLLM 실측 기준).
130
+ * 실무에서는 팀 멤버십 예산(LiteLLM_TeamMembership + litellm_budget_table)이
131
+ * 주로 쓰이므로 그쪽을 먼저 보고, 없으면 키 자체의 max_budget,
132
+ * 그다음 internal user 의 max_budget 순으로 내려갑니다.
133
+ *
134
+ * @returns {{source:'team'|'key'|'user', spend:number, maxBudget:number,
135
+ * budgetResetAt:number|null}|null}
136
+ */
137
+ export function pickBudgetSource(keyInfo, userInfo) {
138
+ const userId = keyInfo?.user_id ?? null;
139
+ const teamId = keyInfo?.team_id ?? null;
140
+ // ① 팀 멤버십 예산: 키의 team_id 에 해당하는 팀에서 내 user_id 의 멤버십을 찾는다.
141
+ const teams = Array.isArray(userInfo?.teams) ? userInfo.teams : [];
142
+ for (const team of teams) {
143
+ if (teamId && team?.team_id !== teamId) continue;
144
+ for (const tm of team?.team_memberships || []) {
145
+ if (userId && tm?.user_id !== userId) continue;
146
+ const max = numOrNull(tm?.litellm_budget_table?.max_budget);
147
+ if (max !== null && max > 0) {
148
+ return {
149
+ source: 'team',
150
+ spend: numOrNull(tm?.spend) ?? 0,
151
+ maxBudget: max,
152
+ budgetResetAt: parseResetMs(tm?.litellm_budget_table?.budget_reset_at),
153
+ };
154
+ }
155
+ }
156
+ }
157
+ // ② 키 자체 예산.
158
+ const keyMax = numOrNull(keyInfo?.max_budget);
159
+ if (keyMax !== null && keyMax > 0) {
160
+ return {
161
+ source: 'key',
162
+ spend: numOrNull(keyInfo?.spend) ?? 0,
163
+ maxBudget: keyMax,
164
+ budgetResetAt: parseResetMs(keyInfo?.budget_reset_at),
165
+ };
166
+ }
167
+ // ③ internal user 예산 (실무에서는 드물지만 폴백으로 유지).
168
+ const u = userInfo?.user_info;
169
+ const userMax = numOrNull(u?.max_budget);
170
+ if (userMax !== null && userMax > 0) {
171
+ return {
172
+ source: 'user',
173
+ spend: numOrNull(u?.spend) ?? 0,
174
+ maxBudget: userMax,
175
+ budgetResetAt: parseResetMs(u?.budget_reset_at),
176
+ };
177
+ }
178
+ return null;
179
+ }
180
+
181
+ /**
182
+ * 실제로 LiteLLM에 물어보고 캐시를 갱신합니다. detached 자식과
183
+ * `litellm-budget --refresh` 명령만 호출합니다.
184
+ * /key/info(키·소속 식별)와 /user/info(팀 멤버십 예산)를 함께 조회합니다.
185
+ */
186
+ export async function refreshBudgetState(env = process.env, fetchImpl = fetch) {
187
+ const gw = gatewayEnv(env);
188
+ if (!gw) return null;
189
+ const controller = new AbortController();
190
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
191
+ const headers = {
192
+ accept: 'application/json',
193
+ authorization: `Bearer ${gw.key}`,
194
+ };
195
+ try {
196
+ const keyRes = await fetchImpl(`${gw.base}/key/info`, { signal: controller.signal, headers });
197
+ if (!keyRes.ok) throw new Error(`LiteLLM responded ${keyRes.status}`);
198
+ const keyBody = await keyRes.json();
199
+ const keyInfo = keyBody && typeof keyBody.info === 'object' ? keyBody.info : null;
200
+ if (!keyInfo) throw new Error('key/info response carried no info object');
201
+ // /user/info 는 팀 멤버십 예산 전용이라 실패해도 키 예산 폴백으로 진행한다.
202
+ let userInfo = null;
203
+ try {
204
+ const userRes = await fetchImpl(`${gw.base}/user/info`, { signal: controller.signal, headers });
205
+ if (userRes.ok) userInfo = await userRes.json();
206
+ } catch (e) {
207
+ debug('litellm-budget:user-info', e);
208
+ }
209
+ const picked = pickBudgetSource(keyInfo, userInfo);
210
+ const next = {
211
+ base: gw.base,
212
+ checkedAt: Date.now(),
213
+ source: picked ? picked.source : null,
214
+ spend: picked ? picked.spend : (numOrNull(keyInfo.spend) ?? 0),
215
+ maxBudget: picked ? picked.maxBudget : null,
216
+ budgetResetAt: picked ? picked.budgetResetAt : null,
217
+ };
218
+ writeBudgetState(next);
219
+ return next;
220
+ } finally {
221
+ clearTimeout(timer);
222
+ }
223
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * month-spend: 이번 달 1일 00시(로컬 시각) 이후 지출한 추정 금액을 계산합니다.
3
+ *
4
+ * LiteLLM·Bedrock처럼 5h/7d cap 이 아예 없는 게이트웨이 사용자도
5
+ * "이번 달에 얼마나 썼는지"는 늘 궁금하므로, 세션 로그를 기반으로
6
+ * 달력 월 단위 지출을 통계선에 상시 노출합니다. 모델 단가는 세션마다
7
+ * 다르므로 세션별로 estimateCost 를 적용해 합산합니다.
8
+ */
9
+
10
+ import { estimateCost } from './cost.js';
11
+
12
+ /** 이번 달 1일 00:00(로컬)의 epoch ms. */
13
+ export function monthStartMs(now = new Date()) {
14
+ return new Date(now.getFullYear(), now.getMonth(), 1).getTime();
15
+ }
16
+
17
+ /** 통계선 라벨에 쓰는 짧은 월 이름 (예: 'Sep'). */
18
+ export function monthLabel(now = new Date()) {
19
+ return now.toLocaleString('en-US', { month: 'short' });
20
+ }
21
+
22
+ /**
23
+ * 월초 이후 세션들의 지출 추정치 합계(USD).
24
+ * endTime 이 월초 이후인 세션만 집계합니다. 세션이 월 경계에 걸치는
25
+ * 경우는 세션 전체를 포함하는데, 경계 세션은 드물고 통계선 지표는
26
+ * 추정치이므로 이 정도 오차는 허용합니다.
27
+ *
28
+ * @param {Array} sessions parseAllSessions 결과
29
+ * @param {Date} [now]
30
+ * @returns {{usd:number, sessions:number, sinceMs:number, label:string}}
31
+ */
32
+ export function monthSpend(sessions, now = new Date()) {
33
+ const since = monthStartMs(now);
34
+ let usd = 0;
35
+ let count = 0;
36
+ for (const s of sessions || []) {
37
+ if (!s || !s.endTime || s.endTime.getTime() < since) continue;
38
+ if (!s.totals) continue;
39
+ try {
40
+ usd += estimateCost(s.totals, s.model).actual;
41
+ count += 1;
42
+ } catch {
43
+ // 단가를 모르는 모델은 합계에서 빠집니다. 통계선에서는 침묵이 낫습니다.
44
+ }
45
+ }
46
+ return { usd, sessions: count, sinceMs: since, label: monthLabel(now) };
47
+ }
@@ -23,6 +23,9 @@ const KNOWN = {
23
23
  // ever surfaces it on stdin we render with a sensible default already.
24
24
  seven_day_sonnet: { short: '7D-S', long: 'Current week (Sonnet)', icon: '🅂', usageLabel: 'weekly (Sonnet)' },
25
25
  seven_day_opus: { short: '7D-O', long: 'Current week (Opus)', icon: '🅾', usageLabel: 'weekly (Opus)' },
26
+ // LiteLLM 게이트웨이 키 예산. rate_limits 가 없는 환경에서 cap 게이지를
27
+ // 대신하는 합성 윈도우라서, 여기 라벨만 있으면 나머지 렌더는 공용 경로를 탄다.
28
+ litellm_budget: { short: 'BUDGET', long: 'LiteLLM key budget', icon: '🔑', usageLabel: 'budget' },
26
29
  };
27
30
 
28
31
  function deriveShort(key) {