claude-token-saver 3.42.7 → 3.43.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.ko.md +4 -2
- package/README.md +15 -4
- package/bin/cli.js +18 -6
- package/package.json +1 -1
- package/src/formatters/statusline.js +9 -5
- package/src/litellm-budget.js +148 -24
package/README.ko.md
CHANGED
|
@@ -559,10 +559,12 @@ v3.26.0부터 트랜스크립트의 모델 ID로 게이트웨이를 감지해
|
|
|
559
559
|
|
|
560
560
|
LiteLLM 프록시로 Bedrock 등을 쓰면 Claude Code stdin 에 `rate_limits` 가 오지 않아 `✦ current`·`📅 weekly` 게이지가 아예 없습니다. 대신 LiteLLM 은 키별 `max_budget` 과 `spend` 를 관리하므로, 그 값을 가져와 같은 지점에 예산 게이지를 그립니다.
|
|
561
561
|
|
|
562
|
-
- 감지 조건: `ANTHROPIC_BASE_URL` 이 공식 엔드포인트가
|
|
562
|
+
- 감지 조건: `ANTHROPIC_BASE_URL` 이 공식 엔드포인트가 아닌 환경. 키는 `ANTHROPIC_AUTH_TOKEN`(또는 `ANTHROPIC_API_KEY`)에서 먼저 찾고, 없으면 `settings.json` 의 `apiKeyHelper` 를 실행해 얻습니다. 헬퍼 실행은 5분에 한 번 뜨는 갱신 프로세스 안에서만 하므로 통계선 렌더는 느려지지 않습니다 (v3.43.0).
|
|
563
|
+
- `/key/info` 가 404 를 주어도 갱신을 멈추지 않습니다. Okta JWT 같은 커스텀 인증을 쓰는 배포에서는 404 가 정상이고, 예산은 `/user/info` 에 들어 있습니다. 두 엔드포인트가 모두 실패할 때만 실패로 봅니다 (v3.43.0).
|
|
564
|
+
- 팀 멤버십이 아니라 internal user 한도로 게이지를 그리게 되면 `budget (user)` 로 출처를 함께 표시합니다. LiteLLM 이 차단 여부를 판정할 때 보는 값은 팀 멤버십 한도라서, 두 값이 수백 배 어긋나는 배포가 있습니다 (v3.43.0).
|
|
563
565
|
- 조회는 LiteLLM 의 `GET /key/info` 와 `GET /user/info` 로 하고, 호출 키 자신의 정보만 받습니다. 예산 출처는 실무에서 가장 많이 쓰는 **팀 멤버십 예산**(team_memberships 의 spend·max_budget)을 먼저 보고, 없으면 키 자체의 max_budget, 그다음 internal user 예산 순으로 고릅니다. 렌더는 캐시 파일만 읽으며, 갱신은 5분에 한 번 분리된 백그라운드 프로세스가 수행합니다 (update-check 와 같은 구조라 statusline 이 네트워크를 기다리지 않습니다).
|
|
564
566
|
- `max_budget` 이 없는 무제한 키는 게이지를 만들지 않습니다. 이 경우에도 `💵` 월 지출 세그먼트는 세션 로그 기반이라 그대로 표시됩니다.
|
|
565
|
-
- 상태 확인: `sprag litellm-budget` (
|
|
567
|
+
- 상태 확인: `sprag litellm-budget` (게이지·사용·잔여 금액 출력) · `--json` (캐시 원본) · `--refresh` (즉시 조회).
|
|
566
568
|
|
|
567
569
|
세션 기본 모델이 sonnet 이면 sonnet 위임 규칙(T1)은 구조적으로 절감이 0입니다. 같은 급으로 내려보내 봐야 차액이 없기 때문이며 이는 정상 동작입니다. 다만 `route-scan rules` 가 이 경우를 "아직 위임 없음"과 같은 문구로 표시해 고장처럼 보였으므로, 이제 현재 기본 모델 기준으로 적용되지 않는다는 사실을 따로 적습니다.
|
|
568
570
|
|
package/README.md
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
> **`claude-token-saver` is the old package name.**
|
|
2
|
+
> Install `sprag-cli` instead — same tool, same binaries, same releases:
|
|
3
|
+
> `npm i -g sprag-cli`
|
|
4
|
+
> (switching needs a swap: `npm uninstall -g claude-token-saver && npm i -g sprag-cli`)
|
|
5
|
+
|
|
1
6
|
<div align="center">
|
|
2
7
|
|
|
3
8
|
<picture>
|
|
@@ -18,6 +23,8 @@
|
|
|
18
23
|
---
|
|
19
24
|
|
|
20
25
|
<!-- ko:begin (generated from README.ko.md — edit that file, then run npm run sync:ko) -->
|
|
26
|
+
**🇰🇷 한국어 문서: [README.ko.md](https://github.com/rootstudioyaml/sprag/blob/main/README.ko.md)** (아래 토글로도 펼칠 수 있습니다.)
|
|
27
|
+
|
|
21
28
|
<details>
|
|
22
29
|
<summary><strong>🇰🇷 한국어로 보기 (클릭하면 한국어 전문이 펼쳐집니다)</strong></summary>
|
|
23
30
|
|
|
@@ -563,10 +570,12 @@ v3.26.0부터 트랜스크립트의 모델 ID로 게이트웨이를 감지해
|
|
|
563
570
|
|
|
564
571
|
LiteLLM 프록시로 Bedrock 등을 쓰면 Claude Code stdin 에 `rate_limits` 가 오지 않아 `✦ current`·`📅 weekly` 게이지가 아예 없습니다. 대신 LiteLLM 은 키별 `max_budget` 과 `spend` 를 관리하므로, 그 값을 가져와 같은 지점에 예산 게이지를 그립니다.
|
|
565
572
|
|
|
566
|
-
- 감지 조건: `ANTHROPIC_BASE_URL` 이 공식 엔드포인트가
|
|
573
|
+
- 감지 조건: `ANTHROPIC_BASE_URL` 이 공식 엔드포인트가 아닌 환경. 키는 `ANTHROPIC_AUTH_TOKEN`(또는 `ANTHROPIC_API_KEY`)에서 먼저 찾고, 없으면 `settings.json` 의 `apiKeyHelper` 를 실행해 얻습니다. 헬퍼 실행은 5분에 한 번 뜨는 갱신 프로세스 안에서만 하므로 통계선 렌더는 느려지지 않습니다 (v3.43.0).
|
|
574
|
+
- `/key/info` 가 404 를 주어도 갱신을 멈추지 않습니다. Okta JWT 같은 커스텀 인증을 쓰는 배포에서는 404 가 정상이고, 예산은 `/user/info` 에 들어 있습니다. 두 엔드포인트가 모두 실패할 때만 실패로 봅니다 (v3.43.0).
|
|
575
|
+
- 팀 멤버십이 아니라 internal user 한도로 게이지를 그리게 되면 `budget (user)` 로 출처를 함께 표시합니다. LiteLLM 이 차단 여부를 판정할 때 보는 값은 팀 멤버십 한도라서, 두 값이 수백 배 어긋나는 배포가 있습니다 (v3.43.0).
|
|
567
576
|
- 조회는 LiteLLM 의 `GET /key/info` 와 `GET /user/info` 로 하고, 호출 키 자신의 정보만 받습니다. 예산 출처는 실무에서 가장 많이 쓰는 **팀 멤버십 예산**(team_memberships 의 spend·max_budget)을 먼저 보고, 없으면 키 자체의 max_budget, 그다음 internal user 예산 순으로 고릅니다. 렌더는 캐시 파일만 읽으며, 갱신은 5분에 한 번 분리된 백그라운드 프로세스가 수행합니다 (update-check 와 같은 구조라 statusline 이 네트워크를 기다리지 않습니다).
|
|
568
577
|
- `max_budget` 이 없는 무제한 키는 게이지를 만들지 않습니다. 이 경우에도 `💵` 월 지출 세그먼트는 세션 로그 기반이라 그대로 표시됩니다.
|
|
569
|
-
- 상태 확인: `sprag litellm-budget` (
|
|
578
|
+
- 상태 확인: `sprag litellm-budget` (게이지·사용·잔여 금액 출력) · `--json` (캐시 원본) · `--refresh` (즉시 조회).
|
|
570
579
|
|
|
571
580
|
세션 기본 모델이 sonnet 이면 sonnet 위임 규칙(T1)은 구조적으로 절감이 0입니다. 같은 급으로 내려보내 봐야 차액이 없기 때문이며 이는 정상 동작입니다. 다만 `route-scan rules` 가 이 경우를 "아직 위임 없음"과 같은 문구로 표시해 고장처럼 보였으므로, 이제 현재 기본 모델 기준으로 적용되지 않는다는 사실을 따로 적습니다.
|
|
572
581
|
|
|
@@ -1312,11 +1321,13 @@ If the detection is wrong, pin it with `sprag mode ttl=5m` (or `ttl=1h`). An exp
|
|
|
1312
1321
|
|
|
1313
1322
|
Behind a LiteLLM proxy (Bedrock and friends), Claude Code's stdin never carries `rate_limits`, so the `✦ current` / `📅 weekly` gauges simply do not exist. LiteLLM does track per-key budgets, so the statusline draws a budget gauge in their place.
|
|
1314
1323
|
|
|
1315
|
-
- Detection: `ANTHROPIC_BASE_URL` points somewhere other than the official endpoint
|
|
1324
|
+
- Detection: `ANTHROPIC_BASE_URL` points somewhere other than the official endpoint. The key comes from `ANTHROPIC_AUTH_TOKEN` / `ANTHROPIC_API_KEY`, or — when neither is set — from running the `apiKeyHelper` script in `settings.json`. The helper only runs inside the 5-minute refresh child, never on the render path (v3.43.0).
|
|
1325
|
+
- A 404 from `/key/info` no longer aborts the refresh: custom-auth deployments (Okta JWT and friends) always 404 there, and the budget lives in `/user/info`. Only a failure of both endpoints counts as a failure (v3.43.0).
|
|
1326
|
+
- When the gauge falls back to the internal-user budget it is labeled `budget (user)`. LiteLLM enforces the team-membership limit, which can differ from the user limit by orders of magnitude (v3.43.0).
|
|
1316
1327
|
- The proxy is asked via `GET /key/info` and `GET /user/info` — only the calling key's own data. Renders read a cache file; a detached background process refreshes it every 5 minutes (same shape as the update check), so the statusline never waits on the network.
|
|
1317
1328
|
- Budget source priority follows real-world usage: the **team-membership budget** (`team_memberships[].spend` + its linked budget table row) first, then the key's own `max_budget`, then the internal-user budget. Verified against a Dockerized LiteLLM, including memberships whose budget diverges from the team max into a separate budget-table row.
|
|
1318
1329
|
- Unlimited keys (no `max_budget`) get no gauge. The `💵` monthly-spend segment still shows, since it comes from session logs.
|
|
1319
|
-
- Inspect with `sprag litellm-budget` (
|
|
1330
|
+
- Inspect with `sprag litellm-budget` (gauge with used/remaining amounts), `--json` (raw cache), or `--refresh` (query now).
|
|
1320
1331
|
|
|
1321
1332
|
One related non-bug: if your session model is already sonnet, a sonnet-delegation (T1) rule can never save anything, because there is no price gap to capture. That is correct, but `route-scan rules` displayed it identically to "no delegations yet", so it now says outright that the rule does not apply at the current default model.
|
|
1322
1333
|
|
package/bin/cli.js
CHANGED
|
@@ -292,10 +292,12 @@ async function main() {
|
|
|
292
292
|
}
|
|
293
293
|
|
|
294
294
|
// Subcommand: litellm-budget · LiteLLM 게이트웨이 키의 max_budget/spend 조회.
|
|
295
|
-
// sprag litellm-budget # 캐시된
|
|
295
|
+
// sprag litellm-budget # 캐시된 예산을 게이지·금액으로 출력
|
|
296
|
+
// sprag litellm-budget --json # 캐시 원본 JSON 출력
|
|
296
297
|
// sprag litellm-budget --refresh # 지금 프록시에 물어봄 (detached 자식이 사용)
|
|
297
298
|
if (args[0] === 'litellm-budget') {
|
|
298
|
-
const {
|
|
299
|
+
const { gatewayBase, readBudgetState, refreshBudgetState, formatBudgetReport } =
|
|
300
|
+
await import('../src/litellm-budget.js');
|
|
299
301
|
const quiet = hasFlag('--quiet');
|
|
300
302
|
if (hasFlag('--refresh')) {
|
|
301
303
|
try {
|
|
@@ -308,12 +310,22 @@ async function main() {
|
|
|
308
310
|
}
|
|
309
311
|
return;
|
|
310
312
|
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
313
|
+
// 읽기 경로는 캐시만 보므로 키를 요구하지 않습니다 (apiKeyHelper 환경 배려).
|
|
314
|
+
const base = gatewayBase();
|
|
315
|
+
if (!base) {
|
|
316
|
+
console.log('게이트웨이가 감지되지 않았습니다 (ANTHROPIC_BASE_URL 필요).');
|
|
314
317
|
return;
|
|
315
318
|
}
|
|
316
|
-
|
|
319
|
+
const state = readBudgetState();
|
|
320
|
+
if (hasFlag('--json')) {
|
|
321
|
+
console.log(JSON.stringify(state, null, 2));
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (state.base !== base || state.maxBudget === undefined) {
|
|
325
|
+
console.log('예산 캐시가 비어 있습니다. `sprag litellm-budget --refresh` 로 먼저 조회하십시오.');
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
for (const line of formatBudgetReport(state)) console.log(line);
|
|
317
329
|
return;
|
|
318
330
|
}
|
|
319
331
|
|
package/package.json
CHANGED
|
@@ -50,7 +50,7 @@ const MAGENTA = fg(167, 139, 250, '\x1b[35m');
|
|
|
50
50
|
const GRAY = fg(100, 116, 139, '\x1b[90m');
|
|
51
51
|
const BOLD = '\x1b[1m';
|
|
52
52
|
|
|
53
|
-
function formatMoney(usd) {
|
|
53
|
+
export function formatMoney(usd) {
|
|
54
54
|
// Boundaries sit at 999.5/999500 so a value that would round up into the
|
|
55
55
|
// next band's width ($999.9 → "$1000") jumps to that band's unit instead —
|
|
56
56
|
// otherwise "$1000" renders visually larger than "$1.0K".
|
|
@@ -81,7 +81,7 @@ function formatPct(v) {
|
|
|
81
81
|
* for ~4% effective precision around the fill edge. Stable monospace width
|
|
82
82
|
* across all terminal fonts that ship Block Elements (U+2580–U+259F).
|
|
83
83
|
*/
|
|
84
|
-
function gaugeBar(pct) {
|
|
84
|
+
export function gaugeBar(pct) {
|
|
85
85
|
const cells = 6;
|
|
86
86
|
const clamped = Math.max(0, Math.min(100, pct));
|
|
87
87
|
const filled = (clamped / 100) * cells; // e.g. 4.32 cells filled
|
|
@@ -640,15 +640,19 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
640
640
|
Number.isFinite(info.maxBudget) && info.maxBudget > 0
|
|
641
641
|
? ` ${formatMoney(Number(info.spend) || 0)}/${formatMoney(info.maxBudget)}`
|
|
642
642
|
: '';
|
|
643
|
+
// LiteLLM 의 internal user 한도는 차단 판정에 쓰이는 값이 아니라서, 팀
|
|
644
|
+
// 멤버십 한도와 수백 배 어긋나는 배포가 있습니다. 그 한도로 그린 게이지는
|
|
645
|
+
// 출처를 밝혀 두어야 사용자가 정확한 값으로 오해하지 않습니다.
|
|
646
|
+
const src = info.source === 'user' ? ' (user)' : '';
|
|
643
647
|
if (isIcon) {
|
|
644
|
-
const labelPart = labels.usageLabel ? `${labels.usageLabel} ` : '';
|
|
648
|
+
const labelPart = labels.usageLabel ? `${labels.usageLabel}${src} ` : '';
|
|
645
649
|
const bar = gaugeBar(pct);
|
|
646
650
|
return `${c(tone)}${labels.icon} ${labelPart}${bar} ${pct}%${money}${tail}${c(RESET)}`;
|
|
647
651
|
}
|
|
648
652
|
if (verbose) {
|
|
649
|
-
return `${c(tone)}${labels.short} cap ${pct}% used${money}${tail}${c(RESET)}`;
|
|
653
|
+
return `${c(tone)}${labels.short}${src} cap ${pct}% used${money}${tail}${c(RESET)}`;
|
|
650
654
|
}
|
|
651
|
-
return `${c(tone)}${labels.short} cap ${pct}%${money}${tail}${c(RESET)}`;
|
|
655
|
+
return `${c(tone)}${labels.short}${src} cap ${pct}%${money}${tail}${c(RESET)}`;
|
|
652
656
|
}
|
|
653
657
|
// Color tone: green <70%, yellow 70-89%, red 90+% (a 90+% window only
|
|
654
658
|
// renders here when a *different* window won the cap-warn chip slot).
|
package/src/litellm-budget.js
CHANGED
|
@@ -18,9 +18,12 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
21
|
-
import { spawn } from 'node:child_process';
|
|
21
|
+
import { spawn, execSync } from 'node:child_process';
|
|
22
22
|
import { join } from 'node:path';
|
|
23
|
+
import { homedir } from 'node:os';
|
|
23
24
|
import { userDataDir } from './paths.js';
|
|
25
|
+
import { gaugeBar, formatMoney } from './formatters/statusline.js';
|
|
26
|
+
import { formatResetClock } from './format-time.js';
|
|
24
27
|
import { cliEntryPath } from './update-check.js';
|
|
25
28
|
import { debug } from './debug.js';
|
|
26
29
|
|
|
@@ -28,22 +31,79 @@ import { debug } from './debug.js';
|
|
|
28
31
|
// 통계선 렌더(수 초 간격)가 프록시를 두들기지 않습니다.
|
|
29
32
|
const CHECK_INTERVAL_MS = 5 * 60 * 1000;
|
|
30
33
|
const FETCH_TIMEOUT_MS = 5000;
|
|
34
|
+
const HELPER_TIMEOUT_MS = 10000;
|
|
31
35
|
|
|
32
36
|
export function budgetStatePath() {
|
|
33
37
|
return join(userDataDir(), 'litellm-budget.json');
|
|
34
38
|
}
|
|
35
39
|
|
|
36
|
-
/**
|
|
37
|
-
|
|
40
|
+
/**
|
|
41
|
+
* 게이트웨이 주소만 판정합니다. 캐시만 읽는 경로는 키가 필요하지 않으므로,
|
|
42
|
+
* 키 확보와 주소 판정을 분리해 둡니다. apiKeyHelper 로만 인증하는 환경에는
|
|
43
|
+
* 환경변수에 토큰이 없어서, 키를 함께 요구하면 렌더 경로가 통째로 탈락합니다.
|
|
44
|
+
*/
|
|
45
|
+
export function gatewayBase(env = process.env) {
|
|
38
46
|
const base = (env.ANTHROPIC_BASE_URL || '').trim().replace(/\/+$/, '');
|
|
39
47
|
if (!base) return null;
|
|
40
48
|
// 공식 엔드포인트를 그대로 가리키면 게이트웨이가 아닙니다.
|
|
41
49
|
if (/^https?:\/\/api\.anthropic\.com/i.test(base)) return null;
|
|
42
|
-
|
|
50
|
+
return base;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 게이트웨이 주소와 키를 함께 돌려줍니다. 둘 중 하나라도 없으면 null. */
|
|
54
|
+
export function gatewayEnv(env = process.env) {
|
|
55
|
+
const base = gatewayBase(env);
|
|
56
|
+
if (!base) return null;
|
|
57
|
+
const key = resolveKey(env);
|
|
43
58
|
if (!key) return null;
|
|
44
59
|
return { base, key };
|
|
45
60
|
}
|
|
46
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Claude Code 와 같은 방식으로 apiKeyHelper 를 실행해 토큰을 얻습니다.
|
|
64
|
+
* 환경변수에 토큰을 두지 않고 헬퍼 스크립트로 매번 발급받는 구성이 공식 인증
|
|
65
|
+
* 방식 가운데 하나이며, 그 구성에서는 statusline 자식 프로세스의 process.env
|
|
66
|
+
* 안에 토큰이 존재하지 않습니다.
|
|
67
|
+
*
|
|
68
|
+
* 헬퍼 실행은 비용이 있으므로 갱신 경로(5분에 한 번 뜨는 detached 자식)에서만
|
|
69
|
+
* 부릅니다. 수 초 간격으로 도는 렌더 경로에서 부르면 통계선이 그만큼 느려집니다.
|
|
70
|
+
*/
|
|
71
|
+
export function keyFromApiKeyHelper(cwd = process.cwd()) {
|
|
72
|
+
const candidates = [
|
|
73
|
+
join(cwd, '.claude', 'settings.local.json'),
|
|
74
|
+
join(cwd, '.claude', 'settings.json'),
|
|
75
|
+
join(homedir(), '.claude', 'settings.json'),
|
|
76
|
+
];
|
|
77
|
+
for (const p of candidates) {
|
|
78
|
+
let helper;
|
|
79
|
+
try {
|
|
80
|
+
helper = JSON.parse(readFileSync(p, 'utf8'))?.apiKeyHelper;
|
|
81
|
+
} catch {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (typeof helper !== 'string' || !helper.trim()) continue;
|
|
85
|
+
try {
|
|
86
|
+
const out = execSync(helper, {
|
|
87
|
+
encoding: 'utf8',
|
|
88
|
+
timeout: HELPER_TIMEOUT_MS,
|
|
89
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
90
|
+
});
|
|
91
|
+
// 진행 로그를 함께 출력하는 헬퍼가 있으므로 마지막 비어 있지 않은 줄을 취합니다.
|
|
92
|
+
const token = out.trim().split(/\r?\n/).filter(Boolean).pop();
|
|
93
|
+
if (token) return token.trim();
|
|
94
|
+
} catch (e) {
|
|
95
|
+
debug('litellm-budget:helper', e);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 환경변수 토큰을 먼저 보고, 없으면 apiKeyHelper 로 내려갑니다. */
|
|
102
|
+
export function resolveKey(env = process.env) {
|
|
103
|
+
const direct = (env.ANTHROPIC_AUTH_TOKEN || env.ANTHROPIC_API_KEY || '').trim();
|
|
104
|
+
return direct || keyFromApiKeyHelper();
|
|
105
|
+
}
|
|
106
|
+
|
|
47
107
|
export function readBudgetState() {
|
|
48
108
|
try {
|
|
49
109
|
const s = JSON.parse(readFileSync(budgetStatePath(), 'utf8'));
|
|
@@ -67,11 +127,11 @@ function writeBudgetState(next) {
|
|
|
67
127
|
* spend:number, maxBudget:number}|null}
|
|
68
128
|
*/
|
|
69
129
|
export function budgetWindow(env = process.env) {
|
|
70
|
-
const
|
|
71
|
-
if (!
|
|
130
|
+
const base = gatewayBase(env);
|
|
131
|
+
if (!base) return null;
|
|
72
132
|
const s = readBudgetState();
|
|
73
133
|
// 다른 프록시의 캐시를 재사용하지 않도록 base 단위로 격리합니다.
|
|
74
|
-
if (s.base !==
|
|
134
|
+
if (s.base !== base) return null;
|
|
75
135
|
const max = Number(s.maxBudget);
|
|
76
136
|
const spend = Number(s.spend);
|
|
77
137
|
if (!Number.isFinite(max) || max <= 0 || !Number.isFinite(spend)) return null;
|
|
@@ -84,19 +144,66 @@ export function budgetWindow(env = process.env) {
|
|
|
84
144
|
resetsAt: resetMs ? Math.round(resetMs / 1000) : null,
|
|
85
145
|
spend,
|
|
86
146
|
maxBudget: max,
|
|
147
|
+
// 렌더러가 출처를 함께 보여 줄 수 있도록 싣습니다. internal user 한도는
|
|
148
|
+
// LiteLLM 이 차단 판정에 쓰는 값이 아니라서 그대로 보여 주면 오해를 부릅니다.
|
|
149
|
+
source: typeof s.source === 'string' ? s.source : null,
|
|
87
150
|
};
|
|
88
151
|
}
|
|
89
152
|
|
|
153
|
+
/**
|
|
154
|
+
* `sprag litellm-budget` 이 출력하는 사람용 보고서입니다. 통계선의 cap 게이지와
|
|
155
|
+
* 같은 모양(비주얼 바 + 백분율 + 금액)을 쓰되, 통계선에서는 폭이 부족해 생략하는
|
|
156
|
+
* 잔여 금액과 예산 출처, 캐시 신선도를 함께 적습니다.
|
|
157
|
+
*
|
|
158
|
+
* @param {object} state - readBudgetState() 가 돌려준 캐시 상태
|
|
159
|
+
* @param {Date} [now]
|
|
160
|
+
* @returns {string[]} 출력할 줄들
|
|
161
|
+
*/
|
|
162
|
+
export function formatBudgetReport(state, now = new Date()) {
|
|
163
|
+
const max = Number(state?.maxBudget);
|
|
164
|
+
const spend = Number(state?.spend);
|
|
165
|
+
if (!Number.isFinite(max) || max <= 0) {
|
|
166
|
+
const spent = Number.isFinite(spend) ? formatMoney(spend) : '알 수 없음';
|
|
167
|
+
return [`예산 한도가 설정되지 않은 키입니다 (무제한). 누적 지출: ${spent}`];
|
|
168
|
+
}
|
|
169
|
+
const used = Number.isFinite(spend) ? Math.max(0, spend) : 0;
|
|
170
|
+
const left = Math.max(0, max - used);
|
|
171
|
+
const pct = Math.min(100, (used / max) * 100);
|
|
172
|
+
const lines = [
|
|
173
|
+
`🔑 budget ${gaugeBar(pct)} ${Math.round(pct)}% ${formatMoney(used)}/${formatMoney(max)}`,
|
|
174
|
+
` 사용 ${formatMoney(used)} · 잔여 ${formatMoney(left)} (${(100 - pct).toFixed(1)}%)`,
|
|
175
|
+
];
|
|
176
|
+
const resetMs = Number(state?.budgetResetAt);
|
|
177
|
+
if (Number.isFinite(resetMs) && resetMs > 0) {
|
|
178
|
+
const clock = formatResetClock(Math.round(resetMs / 1000), now);
|
|
179
|
+
if (clock) lines.push(` 리셋 ${clock}`);
|
|
180
|
+
}
|
|
181
|
+
const labels = { team: '팀 멤버십 예산', key: '키 예산', user: '사용자 예산' };
|
|
182
|
+
if (state?.source) lines.push(` 출처 ${labels[state.source] || state.source}`);
|
|
183
|
+
if (state?.source === 'user') {
|
|
184
|
+
// LiteLLM 은 팀 멤버십 한도로 차단 여부를 판정하므로, 사용자 한도만 잡힌
|
|
185
|
+
// 상태에서는 실제 한도와 크게 어긋날 수 있습니다.
|
|
186
|
+
lines.push(' 주의: 사용자 한도는 차단 판정 기준과 다를 수 있습니다.');
|
|
187
|
+
}
|
|
188
|
+
const checkedAt = Number(state?.checkedAt);
|
|
189
|
+
if (Number.isFinite(checkedAt) && checkedAt > 0) {
|
|
190
|
+
const ageMin = Math.max(0, Math.round((now.getTime() - checkedAt) / 60000));
|
|
191
|
+
lines.push(` 조회 ${ageMin}분 전`);
|
|
192
|
+
}
|
|
193
|
+
return lines;
|
|
194
|
+
}
|
|
195
|
+
|
|
90
196
|
/** 캐시가 오래됐으면 detached 자식으로 갱신을 예약합니다. 즉시 반환. */
|
|
91
197
|
export function maybeSpawnBudgetCheck(env = process.env) {
|
|
92
|
-
|
|
93
|
-
|
|
198
|
+
// 키 확보는 자식이 담당합니다. 렌더 경로에서 헬퍼를 돌리면 통계선이 느려집니다.
|
|
199
|
+
const base = gatewayBase(env);
|
|
200
|
+
if (!base) return false;
|
|
94
201
|
const s = readBudgetState();
|
|
95
202
|
const age = Date.now() - (Number(s.checkedAt) || 0);
|
|
96
|
-
if (s.base ===
|
|
203
|
+
if (s.base === base && age < CHECK_INTERVAL_MS) return false;
|
|
97
204
|
// 오프라인/오류 시 렌더마다 자식을 다시 띄우지 않도록 시도 시각을 먼저 기록합니다.
|
|
98
205
|
try {
|
|
99
|
-
writeBudgetState({ ...s, base
|
|
206
|
+
writeBudgetState({ ...s, base, checkedAt: Date.now() });
|
|
100
207
|
} catch (e) {
|
|
101
208
|
debug('litellm-budget:stamp', e);
|
|
102
209
|
return false;
|
|
@@ -135,7 +242,10 @@ function parseResetMs(v) {
|
|
|
135
242
|
* budgetResetAt:number|null}|null}
|
|
136
243
|
*/
|
|
137
244
|
export function pickBudgetSource(keyInfo, userInfo) {
|
|
138
|
-
|
|
245
|
+
// /key/info 가 404 인 게이트웨이(커스텀 인증)에서는 keyInfo 가 비므로,
|
|
246
|
+
// /user/info 가 함께 주는 내 user_id 를 폴백으로 씁니다. 이 폴백이 없으면
|
|
247
|
+
// 팀 전체 멤버십을 돌려주는 배포에서 남의 예산을 집을 수 있습니다.
|
|
248
|
+
const userId = keyInfo?.user_id ?? userInfo?.user_info?.user_id ?? null;
|
|
139
249
|
const teamId = keyInfo?.team_id ?? null;
|
|
140
250
|
// ① 팀 멤버십 예산: 키의 team_id 에 해당하는 팀에서 내 user_id 의 멤버십을 찾는다.
|
|
141
251
|
const teams = Array.isArray(userInfo?.teams) ? userInfo.teams : [];
|
|
@@ -184,34 +294,48 @@ export function pickBudgetSource(keyInfo, userInfo) {
|
|
|
184
294
|
* /key/info(키·소속 식별)와 /user/info(팀 멤버십 예산)를 함께 조회합니다.
|
|
185
295
|
*/
|
|
186
296
|
export async function refreshBudgetState(env = process.env, fetchImpl = fetch) {
|
|
187
|
-
const
|
|
188
|
-
if (!
|
|
297
|
+
const base = gatewayBase(env);
|
|
298
|
+
if (!base) return null;
|
|
299
|
+
const key = resolveKey(env);
|
|
300
|
+
if (!key) return null;
|
|
189
301
|
const controller = new AbortController();
|
|
190
302
|
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
191
303
|
const headers = {
|
|
192
304
|
accept: 'application/json',
|
|
193
|
-
authorization: `Bearer ${
|
|
305
|
+
authorization: `Bearer ${key}`,
|
|
194
306
|
};
|
|
195
307
|
try {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
308
|
+
// /key/info 는 커스텀 인증(JWT)을 쓰는 배포에서 404 가 정상입니다. 발급된
|
|
309
|
+
// 토큰에 해당하는 행이 LiteLLM_VerificationToken 에 없기 때문입니다. 예산
|
|
310
|
+
// 자체는 /user/info 에 있으므로, 여기서 중단하면 안 됩니다.
|
|
311
|
+
let keyInfo = null;
|
|
312
|
+
try {
|
|
313
|
+
const keyRes = await fetchImpl(`${base}/key/info`, { signal: controller.signal, headers });
|
|
314
|
+
if (keyRes.ok) {
|
|
315
|
+
const keyBody = await keyRes.json();
|
|
316
|
+
if (keyBody && typeof keyBody.info === 'object') keyInfo = keyBody.info;
|
|
317
|
+
} else {
|
|
318
|
+
debug('litellm-budget:key-info', new Error(`LiteLLM responded ${keyRes.status}`));
|
|
319
|
+
}
|
|
320
|
+
} catch (e) {
|
|
321
|
+
debug('litellm-budget:key-info', e);
|
|
322
|
+
}
|
|
202
323
|
let userInfo = null;
|
|
203
324
|
try {
|
|
204
|
-
const userRes = await fetchImpl(`${
|
|
325
|
+
const userRes = await fetchImpl(`${base}/user/info`, { signal: controller.signal, headers });
|
|
205
326
|
if (userRes.ok) userInfo = await userRes.json();
|
|
327
|
+
else debug('litellm-budget:user-info', new Error(`LiteLLM responded ${userRes.status}`));
|
|
206
328
|
} catch (e) {
|
|
207
329
|
debug('litellm-budget:user-info', e);
|
|
208
330
|
}
|
|
331
|
+
// 둘 다 받지 못했을 때만 실패로 봅니다.
|
|
332
|
+
if (!keyInfo && !userInfo) throw new Error('neither key/info nor user/info returned data');
|
|
209
333
|
const picked = pickBudgetSource(keyInfo, userInfo);
|
|
210
334
|
const next = {
|
|
211
|
-
base
|
|
335
|
+
base,
|
|
212
336
|
checkedAt: Date.now(),
|
|
213
337
|
source: picked ? picked.source : null,
|
|
214
|
-
spend: picked ? picked.spend : (numOrNull(keyInfo
|
|
338
|
+
spend: picked ? picked.spend : (numOrNull(keyInfo?.spend) ?? 0),
|
|
215
339
|
maxBudget: picked ? picked.maxBudget : null,
|
|
216
340
|
budgetResetAt: picked ? picked.budgetResetAt : null,
|
|
217
341
|
};
|