sprag-cli 3.40.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 +21 -0
- package/README.ko.md +637 -0
- package/README.md +758 -0
- package/bin/cli.js +801 -0
- package/examples/statusline-command.ps1 +43 -0
- package/examples/statusline-command.sh +36 -0
- package/package.json +62 -0
- package/presets/cohesion/cohesion-en.md +26 -0
- package/presets/doc2md/convert.py +363 -0
- package/presets/korean-style/LICENSE-fluent-korean +21 -0
- package/presets/korean-style/fluent-korean.md +52 -0
- package/presets/korean-style/supplement.md +93 -0
- package/presets/model-rules.json +115 -0
- package/presets/ratchet-rules.json +38 -0
- package/src/advice.js +564 -0
- package/src/agents.js +52 -0
- package/src/brief.js +264 -0
- package/src/caps-cache.js +84 -0
- package/src/cli-args.js +51 -0
- package/src/cohesion.js +70 -0
- package/src/commands/brief.js +31 -0
- package/src/commands/cohesion.js +59 -0
- package/src/commands/compact-window.js +93 -0
- package/src/commands/doc2md.js +166 -0
- package/src/commands/feedback.js +132 -0
- package/src/commands/handoff.js +33 -0
- package/src/commands/harness.js +459 -0
- package/src/commands/history.js +46 -0
- package/src/commands/install.js +358 -0
- package/src/commands/korean.js +220 -0
- package/src/commands/last.js +151 -0
- package/src/commands/mode.js +46 -0
- package/src/commands/route-scan.js +454 -0
- package/src/commands/seed.js +105 -0
- package/src/commands/uninstall.js +42 -0
- package/src/commands/update-check.js +77 -0
- package/src/commands/upgrade.js +68 -0
- package/src/compact-window.js +205 -0
- package/src/config.js +232 -0
- package/src/cost.js +253 -0
- package/src/debug.js +29 -0
- package/src/demo.js +331 -0
- package/src/doc2md-ledger.cjs +227 -0
- package/src/doc2md.cjs +997 -0
- package/src/fig2md-runner.cjs +21 -0
- package/src/fig2md.cjs +191 -0
- package/src/first-run-note.js +63 -0
- package/src/format-time.js +44 -0
- package/src/formatters/csv.js +8 -0
- package/src/formatters/json.js +3 -0
- package/src/formatters/statusline.js +750 -0
- package/src/formatters/table.js +299 -0
- package/src/handoff.js +161 -0
- package/src/harness-analyzer.cjs +264 -0
- package/src/harness-templates.js +153 -0
- package/src/harness.js +613 -0
- package/src/history.js +383 -0
- package/src/hook-manager.js +96 -0
- package/src/hook.cjs +196 -0
- package/src/installer.js +614 -0
- package/src/korean-lint.cjs +303 -0
- package/src/korean-style.js +187 -0
- package/src/litellm-budget.js +223 -0
- package/src/model-alias.js +484 -0
- package/src/model-rules.js +527 -0
- package/src/month-spend.js +47 -0
- package/src/parser.js +330 -0
- package/src/paths.js +41 -0
- package/src/prompt.js +52 -0
- package/src/route-scan.js +832 -0
- package/src/savings-ledger.js +137 -0
- package/src/seed-rules.js +280 -0
- package/src/session-cache.js +160 -0
- package/src/session-records.js +188 -0
- package/src/stats.js +380 -0
- package/src/stdin-payload.js +122 -0
- package/src/subagent-records.js +214 -0
- package/src/update-check.js +201 -0
- package/src/window-labels.js +64 -0
|
@@ -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
|
+
}
|