sprag-cli 3.46.1 → 3.47.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.
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Subcommand: profile-map. LiteLLM 게이트웨이의 프로파일 ID → 모델 계열 매핑을
3
+ * 보여 주거나 갱신합니다.
4
+ *
5
+ * sprag profile-map # 캐시된 매핑을 사람이 읽을 수 있게 출력
6
+ * sprag profile-map --json # profile-map.json 원본을 그대로 출력
7
+ * sprag profile-map --refresh # LiteLLM GET /model/info 를 지금 호출해 캐시를 갱신
8
+ * sprag profile-map --quiet # 위 어느 모드든 출력을 억제 (스크립트/자식 프로세스용)
9
+ *
10
+ * 왜 이 명령이 필요한가: model-alias.js 의 학습 경로(투표)는 서브에이전트
11
+ * 실행이 MIN_VOTES 건 이상 쌓여야 판정이 서므로, 새 프로파일 ID 나 사용량이
12
+ * 적은 티어는 오랫동안(또는 영원히) role=null 로 남는다. 반면 LiteLLM
13
+ * `/model/info` 는 게이트웨이가 이미 알고 있는 배포 목록을 즉시 돌려주므로,
14
+ * 이 명령으로 그 매핑을 캐시해 두면 학습을 기다릴 필요가 없다. 실제 판정은
15
+ * model-alias.js 의 resolveModelAlias() 가 하고, 이 명령은 그 판정에 쓰이는
16
+ * 세 출처(override/gateway/learned)를 사람이 확인할 수 있게 보여 줄 뿐이다.
17
+ *
18
+ * 왜 --refresh 에서만 resolveKey() 를 부르는가: apiKeyHelper 가 인증 토큰의
19
+ * 수명(TTL)을 소유한다. 이 명령의 읽기 경로(플래그 없음/--json)는 캐시 파일만
20
+ * 보므로 키가 전혀 필요 없고, --refresh 는 사람이 터미널에서 직접 실행하는
21
+ * 경로라서 헬퍼가 Okta 로그인 창을 띄우더라도 괜찮다(사용자 지시). 얻은 키는
22
+ * fetch 요청 헤더에만 쓰고, 화면에 출력하거나 파일에 쓰지 않는다.
23
+ */
24
+
25
+ export async function run({ args, hasFlag }) {
26
+ const { userLanguage } = await import('../config.js');
27
+ const { loadProfileMap, aliasForRole, MIN_VOTES } = await import('../model-alias.js');
28
+ const { gatewayBase, resolveKey } = await import('../gateway-auth.js');
29
+ const { readGatewayModelMap, refreshGatewayModelMap } = await import('../litellm-models.js');
30
+
31
+ const lang = userLanguage();
32
+ const ko = lang === 'ko';
33
+ const quiet = hasFlag('--quiet');
34
+ const say = (line) => { if (!quiet) console.log(line); };
35
+
36
+ const base = gatewayBase();
37
+ if (!base) {
38
+ // 오류가 아니다: 공식 API 를 직접 쓰는 사용자에게는 이 명령이 원래 할 일이
39
+ // 없다. --refresh 와 --json 도 base 없이는 의미가 없으므로 여기서 함께
40
+ // 걸러낸다.
41
+ say(ko
42
+ ? '게이트웨이가 감지되지 않았습니다 (ANTHROPIC_BASE_URL 필요). LiteLLM 등 게이트웨이 뒤에서 실행할 때만 쓰는 명령입니다.'
43
+ : 'No gateway detected (ANTHROPIC_BASE_URL required). This command only matters behind a gateway such as LiteLLM.');
44
+ return;
45
+ }
46
+
47
+ if (hasFlag('--refresh')) {
48
+ const key = resolveKey();
49
+ if (!key) {
50
+ say(ko
51
+ ? 'apiKeyHelper 가 토큰을 내주지 못했습니다. 인증을 마친 뒤 다시 실행하십시오.'
52
+ : 'apiKeyHelper did not return a token. Authenticate, then run this again.');
53
+ process.exitCode = 1;
54
+ return;
55
+ }
56
+ const result = await refreshGatewayModelMap({ base, key });
57
+ if (!result) {
58
+ say(ko
59
+ ? `LiteLLM 에서 모델 정보를 가져오지 못했습니다 (GET ${base}/model/info). CTS_DEBUG=1 로 다시 실행하면 원인이 보입니다.`
60
+ : `Could not fetch model info from LiteLLM (GET ${base}/model/info). Re-run with CTS_DEBUG=1 to see why.`);
61
+ process.exitCode = 1;
62
+ return;
63
+ }
64
+ const resolved = Object.keys(result.aliases).length;
65
+ say(ko
66
+ ? `배포 ${result.count}건 중 ${resolved}개 키를 해석했습니다${result.skipped.length ? ` (미해석 ${result.skipped.length}건)` : ''}.`
67
+ : `Resolved ${resolved} key(s) out of ${result.count} deployment(s)${result.skipped.length ? ` (${result.skipped.length} unresolved)` : ''}.`);
68
+ if (result.skipped.length) {
69
+ say(ko
70
+ ? ` 미해석 model_name: ${result.skipped.join(', ')}`
71
+ : ` Unresolved model_name: ${result.skipped.join(', ')}`);
72
+ }
73
+ return;
74
+ }
75
+
76
+ const map = loadProfileMap();
77
+ const gatewayMap = readGatewayModelMap();
78
+
79
+ if (hasFlag('--json')) {
80
+ say(JSON.stringify(map, null, 2));
81
+ return;
82
+ }
83
+
84
+ say(ko ? `게이트웨이: ${base}` : `Gateway: ${base}`);
85
+
86
+ if (gatewayMap) {
87
+ const fetchedMs = Date.parse(gatewayMap.fetchedAt);
88
+ const ageMin = Number.isFinite(fetchedMs) ? Math.max(0, Math.round((Date.now() - fetchedMs) / 60000)) : null;
89
+ const resolved = Object.keys(gatewayMap.aliases || {}).length;
90
+ say(ko
91
+ ? `조회 ${ageMin === null ? '알 수 없음' : `${ageMin}분 전`} · 배포 ${gatewayMap.count}건 · 해석된 키 ${resolved}개`
92
+ : `checked ${ageMin === null ? 'unknown' : `${ageMin}m ago`} · ${gatewayMap.count} deployment(s) · ${resolved} key(s) resolved`);
93
+ } else {
94
+ say(ko
95
+ ? '게이트웨이 모델맵이 아직 없습니다. `sprag profile-map --refresh` 로 지금 가져오십시오.'
96
+ : 'No gateway model map cached yet. Fetch it now with `sprag profile-map --refresh`.');
97
+ }
98
+
99
+ const overrideEntries = Object.entries(map.modelAliases || {});
100
+ if (overrideEntries.length) {
101
+ say(ko ? '\n사용자 매핑 (modelAliases):' : '\nUser overrides (modelAliases):');
102
+ for (const [pattern, alias] of overrideEntries) {
103
+ say(` ${pattern} → ${alias} (override)`);
104
+ }
105
+ }
106
+
107
+ const gwAliases = gatewayMap?.aliases || {};
108
+ const learned = map.learned || {};
109
+ // Set 으로 합치는 이유: 같은 프로파일 ID 가 두 출처 모두에 나타날 수 있고,
110
+ // 그때 서로 다른 답을 준다면 그 자체가 사용자에게 알려야 할 신호다(학습이
111
+ // 틀렸거나 게이트웨이 설정이 바뀌었다는 뜻).
112
+ const keys = [...new Set([...Object.keys(gwAliases), ...Object.keys(learned)])].sort();
113
+
114
+ if (keys.length) {
115
+ say(ko ? '\n게이트웨이 · 학습 매핑:' : '\nGateway & learned mappings:');
116
+ for (const key of keys) {
117
+ const gwAlias = gwAliases[key];
118
+ const entry = learned[key];
119
+
120
+ if (gwAlias) {
121
+ say(` ${key} → ${gwAlias} (gateway)`);
122
+ if (entry?.role) {
123
+ const learnedAlias = aliasForRole(entry.role);
124
+ if (learnedAlias && learnedAlias !== gwAlias) {
125
+ say(ko
126
+ ? ` ⚠ 학습 결과는 ${learnedAlias} 였습니다. 학습이 틀렸거나 게이트웨이 설정이 바뀌었을 수 있습니다.`
127
+ : ` ⚠ the learned result was ${learnedAlias}. Either the learning was wrong, or the gateway config changed.`);
128
+ }
129
+ } else if (entry && !entry.role) {
130
+ say(ko
131
+ ? ` 학습은 표 ${entry.total || 0}개로 판정 대기였지만, 게이트웨이가 이미 해석했으므로 더 이상 문제되지 않습니다.`
132
+ : ` Learning was still undecided at ${entry.total || 0} vote(s), but the gateway already resolves this, so it is no longer an issue.`);
133
+ }
134
+ continue;
135
+ }
136
+
137
+ if (entry?.role) {
138
+ const learnedAlias = aliasForRole(entry.role);
139
+ say(learnedAlias
140
+ ? ` ${key} → ${learnedAlias} (learned · ${entry.role})`
141
+ : (ko ? ` ${key} → ${entry.role} 역할 · 별칭 미확정 (learned)` : ` ${key} → ${entry.role} role · alias undecided (learned)`));
142
+ } else if (entry) {
143
+ say(ko
144
+ ? ` ${key} → 표 ${entry.total || 0}개 · 판정 대기 (기준 ${MIN_VOTES}개, learned)`
145
+ : ` ${key} → ${entry.total || 0} vote(s) · undecided (needs ${MIN_VOTES}, learned)`);
146
+ }
147
+ }
148
+ }
149
+
150
+ if (!overrideEntries.length && !keys.length) {
151
+ say(ko
152
+ ? '\n등록된 매핑이 없습니다.'
153
+ : '\nNo mappings recorded yet.');
154
+ }
155
+
156
+ if (gatewayMap?.skipped?.length) {
157
+ say(ko
158
+ ? `\n게이트웨이가 계열명을 알아내지 못한 배포: ${gatewayMap.skipped.join(', ')}.`
159
+ : `\nDeployments the gateway could not name a family for: ${gatewayMap.skipped.join(', ')}.`);
160
+ say(ko
161
+ ? ' profile-map.json 의 modelAliases 에 해당 이름을 직접 매핑하십시오.'
162
+ : ' Map those names directly under modelAliases in profile-map.json.');
163
+ }
164
+ }
@@ -183,15 +183,19 @@ export async function run({ args, hasFlag, numArg }) {
183
183
  const { MIN_VOTES } = await import('../model-alias.js');
184
184
  const learning = scan.unresolvedRuns < MIN_VOTES;
185
185
  const remedyKo = learning
186
- ? `\n 위임이 ${MIN_VOTES}건 이상 쌓이면 자동으로 해석될 수 있습니다. 그때까지 기다렸다가 다시 확인하고,`
187
- + '\n 게이트웨이가 모델 이름을 아예 알려 주지 않는 환경이라면 profile-map.json 의 modelAliases 에'
188
- + '\n 해당 ID 를 매핑한 뒤 route-scan --refresh 를 실행하십시오.'
189
- : '\n profile-map.json 의 modelAliases 에 해당 ID 를 매핑한 뒤 route-scan --refresh 를 실행하십시오.';
186
+ ? `\n 위임이 ${MIN_VOTES}건 이상 쌓이면 자동으로 해석될 수 있습니다. 그때까지 기다리는 동안`
187
+ + '\n sprag profile-map --refresh 를 먼저 실행해 보십시오. LiteLLM 게이트웨이라면 배포 목록에서'
188
+ + '\n 매핑을 곧바로 가져옵니다. 그래도 남는 ID 는 profile-map.json 의 modelAliases 에 직접'
189
+ + '\n 매핑한 뒤 route-scan --refresh 를 실행하십시오.'
190
+ : '\n sprag profile-map --refresh 를 먼저 실행해 보십시오. LiteLLM 게이트웨이라면 배포 목록에서 매핑을 곧바로 가져옵니다.'
191
+ + '\n 그래도 남는 ID 는 profile-map.json 의 modelAliases 에 직접 매핑한 뒤 route-scan --refresh 를 실행하십시오.';
190
192
  const remedyEn = learning
191
- ? `\n These may resolve on their own once ${MIN_VOTES} or more delegated runs accumulate — check again then.`
192
- + '\n If the gateway never reports a model name at all, map the id under modelAliases in'
193
+ ? `\n These may resolve on their own once ${MIN_VOTES} or more delegated runs accumulate; check again then.`
194
+ + '\n Meanwhile, try sprag profile-map --refresh first: on a LiteLLM gateway it pulls the mapping'
195
+ + '\n straight from the deployment list. Anything still left over, map it under modelAliases in'
193
196
  + '\n profile-map.json and run route-scan --refresh.'
194
- : '\n Map it under modelAliases in profile-map.json, then run route-scan --refresh.';
197
+ : '\n Try sprag profile-map --refresh first: on a LiteLLM gateway it pulls the mapping straight from the deployment list.'
198
+ + '\n Anything still left over, map it under modelAliases in profile-map.json and run route-scan --refresh.';
195
199
  console.log(lang === 'ko'
196
200
  ? `\n⚠ 해석되지 않은 모델 ID 때문에 위임 ${scan.unresolvedRuns}건이 집계에서 제외됐습니다${ids ? ` (${ids})` : ''}.` + remedyKo
197
201
  : `\n⚠ ${scan.unresolvedRuns} delegated run(s) were excluded — unpriceable model id${ids ? ` (${ids})` : ''}.` + remedyEn);
@@ -0,0 +1,86 @@
1
+ /**
2
+ * gateway-auth: gateway address detection + auth-token resolution, split out
3
+ * as a leaf module so other modules can read the LiteLLM gateway's address
4
+ * and key without risking a circular import.
5
+ *
6
+ * Why this file exists: src/formatters/statusline.js imports MIN_VOTES from
7
+ * src/model-alias.js, and src/litellm-budget.js imports from
8
+ * formatters/statusline.js. If model-alias.js also needed gatewayBase() from
9
+ * litellm-budget.js (to resolve gateway-supplied aliases), the import graph
10
+ * would close a cycle: model-alias.js → litellm-budget.js →
11
+ * formatters/statusline.js → model-alias.js. This module carries no
12
+ * dependency on either side of that cycle, so both litellm-budget.js and
13
+ * model-alias.js can import it safely.
14
+ *
15
+ * This module never persists the token it resolves. apiKeyHelper owns that
16
+ * token's lifecycle (short TTL, refreshed on its own schedule); a cached
17
+ * copy here would drift out of sync with that lifecycle. Callers receive the
18
+ * key as a return value only and are expected to pass it along as a plain
19
+ * argument, not to write it to disk, argv, logs, or error messages.
20
+ */
21
+
22
+ import { readFileSync } from 'node:fs';
23
+ import { execSync } from 'node:child_process';
24
+ import { join } from 'node:path';
25
+ import { homedir } from 'node:os';
26
+ import { debug } from './debug.js';
27
+
28
+ export const HELPER_TIMEOUT_MS = 10000;
29
+
30
+ /**
31
+ * 게이트웨이 주소만 판정합니다. 캐시만 읽는 경로는 키가 필요하지 않으므로,
32
+ * 키 확보와 주소 판정을 분리해 둡니다. apiKeyHelper 로만 인증하는 환경에는
33
+ * 환경변수에 토큰이 없어서, 키를 함께 요구하면 렌더 경로가 통째로 탈락합니다.
34
+ */
35
+ export function gatewayBase(env = process.env) {
36
+ const base = (env.ANTHROPIC_BASE_URL || '').trim().replace(/\/+$/, '');
37
+ if (!base) return null;
38
+ // 공식 엔드포인트를 그대로 가리키면 게이트웨이가 아닙니다.
39
+ if (/^https?:\/\/api\.anthropic\.com/i.test(base)) return null;
40
+ return base;
41
+ }
42
+
43
+ /**
44
+ * Claude Code 와 같은 방식으로 apiKeyHelper 를 실행해 토큰을 얻습니다.
45
+ * 환경변수에 토큰을 두지 않고 헬퍼 스크립트로 매번 발급받는 구성이 공식 인증
46
+ * 방식 가운데 하나이며, 그 구성에서는 statusline 자식 프로세스의 process.env
47
+ * 안에 토큰이 존재하지 않습니다.
48
+ *
49
+ * 헬퍼 실행은 비용이 있으므로 갱신 경로(5분에 한 번 뜨는 detached 자식)에서만
50
+ * 부릅니다. 수 초 간격으로 도는 렌더 경로에서 부르면 통계선이 그만큼 느려집니다.
51
+ */
52
+ export function keyFromApiKeyHelper(cwd = process.cwd()) {
53
+ const candidates = [
54
+ join(cwd, '.claude', 'settings.local.json'),
55
+ join(cwd, '.claude', 'settings.json'),
56
+ join(homedir(), '.claude', 'settings.json'),
57
+ ];
58
+ for (const p of candidates) {
59
+ let helper;
60
+ try {
61
+ helper = JSON.parse(readFileSync(p, 'utf8'))?.apiKeyHelper;
62
+ } catch {
63
+ continue;
64
+ }
65
+ if (typeof helper !== 'string' || !helper.trim()) continue;
66
+ try {
67
+ const out = execSync(helper, {
68
+ encoding: 'utf8',
69
+ timeout: HELPER_TIMEOUT_MS,
70
+ stdio: ['ignore', 'pipe', 'ignore'],
71
+ });
72
+ // 진행 로그를 함께 출력하는 헬퍼가 있으므로 마지막 비어 있지 않은 줄을 취합니다.
73
+ const token = out.trim().split(/\r?\n/).filter(Boolean).pop();
74
+ if (token) return token.trim();
75
+ } catch (e) {
76
+ debug('gateway-auth:helper', e);
77
+ }
78
+ }
79
+ return null;
80
+ }
81
+
82
+ /** 환경변수 토큰을 먼저 보고, 없으면 apiKeyHelper 로 내려갑니다. */
83
+ export function resolveKey(env = process.env) {
84
+ const direct = (env.ANTHROPIC_AUTH_TOKEN || env.ANTHROPIC_API_KEY || '').trim();
85
+ return direct || keyFromApiKeyHelper();
86
+ }
@@ -15,42 +15,39 @@
15
15
  * 엔드포인트는 LiteLLM의 `GET {base}/key/info` 이며, 호출한 키 자신의 정보를
16
16
  * 돌려줍니다. 응답의 info.max_budget / info.spend / info.budget_reset_at 을
17
17
  * 사용합니다. max_budget 이 null 이면(무제한 키) 게이지를 만들지 않습니다.
18
+ *
19
+ * 이 파일은 resolveKey() 가 돌려준 토큰을 캐시 파일에 쓰지 않습니다. 그 키는
20
+ * apiKeyHelper 가 소유한 TTL 을 그대로 따라야 하므로, 여기서 사본을 만들면
21
+ * 헬퍼의 만료·재발급 주기와 어긋납니다(사용자 지시).
18
22
  */
19
23
 
20
24
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
21
- import { spawn, execSync } from 'node:child_process';
25
+ import { spawn } from 'node:child_process';
22
26
  import { join } from 'node:path';
23
- import { homedir } from 'node:os';
24
27
  import { userDataDir } from './paths.js';
25
28
  import { gaugeBar, formatMoney } from './formatters/statusline.js';
26
29
  import { labelForKey } from './window-labels.js';
27
30
  import { formatResetClock } from './format-time.js';
28
31
  import { cliEntryPath } from './update-check.js';
29
32
  import { debug } from './debug.js';
33
+ // 게이트웨이 주소 판정과 키 확보는 leaf 모듈로 옮겼습니다: model-alias.js 가
34
+ // 게이트웨이 별칭을 조회하려면 이 함수들이 필요한데, model-alias.js 가 이
35
+ // 파일을 직접 import 하면 model-alias.js → litellm-budget.js →
36
+ // formatters/statusline.js → model-alias.js 순환이 생깁니다. 이 파일은 기존
37
+ // 호출부(예: bin/cli.js) 와의 호환을 위해 같은 이름으로 재수출만 합니다.
38
+ import { HELPER_TIMEOUT_MS, gatewayBase, keyFromApiKeyHelper, resolveKey } from './gateway-auth.js';
39
+
40
+ export { HELPER_TIMEOUT_MS, gatewayBase, keyFromApiKeyHelper, resolveKey };
30
41
 
31
42
  // 예산은 분 단위로 변하지 않습니다. 5분이면 게이지 용도로 충분히 신선하고,
32
43
  // 통계선 렌더(수 초 간격)가 프록시를 두들기지 않습니다.
33
44
  const CHECK_INTERVAL_MS = 5 * 60 * 1000;
34
45
  const FETCH_TIMEOUT_MS = 5000;
35
- const HELPER_TIMEOUT_MS = 10000;
36
46
 
37
47
  export function budgetStatePath() {
38
48
  return join(userDataDir(), 'litellm-budget.json');
39
49
  }
40
50
 
41
- /**
42
- * 게이트웨이 주소만 판정합니다. 캐시만 읽는 경로는 키가 필요하지 않으므로,
43
- * 키 확보와 주소 판정을 분리해 둡니다. apiKeyHelper 로만 인증하는 환경에는
44
- * 환경변수에 토큰이 없어서, 키를 함께 요구하면 렌더 경로가 통째로 탈락합니다.
45
- */
46
- export function gatewayBase(env = process.env) {
47
- const base = (env.ANTHROPIC_BASE_URL || '').trim().replace(/\/+$/, '');
48
- if (!base) return null;
49
- // 공식 엔드포인트를 그대로 가리키면 게이트웨이가 아닙니다.
50
- if (/^https?:\/\/api\.anthropic\.com/i.test(base)) return null;
51
- return base;
52
- }
53
-
54
51
  /** 게이트웨이 주소와 키를 함께 돌려줍니다. 둘 중 하나라도 없으면 null. */
55
52
  export function gatewayEnv(env = process.env) {
56
53
  const base = gatewayBase(env);
@@ -60,51 +57,6 @@ export function gatewayEnv(env = process.env) {
60
57
  return { base, key };
61
58
  }
62
59
 
63
- /**
64
- * Claude Code 와 같은 방식으로 apiKeyHelper 를 실행해 토큰을 얻습니다.
65
- * 환경변수에 토큰을 두지 않고 헬퍼 스크립트로 매번 발급받는 구성이 공식 인증
66
- * 방식 가운데 하나이며, 그 구성에서는 statusline 자식 프로세스의 process.env
67
- * 안에 토큰이 존재하지 않습니다.
68
- *
69
- * 헬퍼 실행은 비용이 있으므로 갱신 경로(5분에 한 번 뜨는 detached 자식)에서만
70
- * 부릅니다. 수 초 간격으로 도는 렌더 경로에서 부르면 통계선이 그만큼 느려집니다.
71
- */
72
- export function keyFromApiKeyHelper(cwd = process.cwd()) {
73
- const candidates = [
74
- join(cwd, '.claude', 'settings.local.json'),
75
- join(cwd, '.claude', 'settings.json'),
76
- join(homedir(), '.claude', 'settings.json'),
77
- ];
78
- for (const p of candidates) {
79
- let helper;
80
- try {
81
- helper = JSON.parse(readFileSync(p, 'utf8'))?.apiKeyHelper;
82
- } catch {
83
- continue;
84
- }
85
- if (typeof helper !== 'string' || !helper.trim()) continue;
86
- try {
87
- const out = execSync(helper, {
88
- encoding: 'utf8',
89
- timeout: HELPER_TIMEOUT_MS,
90
- stdio: ['ignore', 'pipe', 'ignore'],
91
- });
92
- // 진행 로그를 함께 출력하는 헬퍼가 있으므로 마지막 비어 있지 않은 줄을 취합니다.
93
- const token = out.trim().split(/\r?\n/).filter(Boolean).pop();
94
- if (token) return token.trim();
95
- } catch (e) {
96
- debug('litellm-budget:helper', e);
97
- }
98
- }
99
- return null;
100
- }
101
-
102
- /** 환경변수 토큰을 먼저 보고, 없으면 apiKeyHelper 로 내려갑니다. */
103
- export function resolveKey(env = process.env) {
104
- const direct = (env.ANTHROPIC_AUTH_TOKEN || env.ANTHROPIC_API_KEY || '').trim();
105
- return direct || keyFromApiKeyHelper();
106
- }
107
-
108
60
  export function readBudgetState() {
109
61
  try {
110
62
  const s = JSON.parse(readFileSync(budgetStatePath(), 'utf8'));
@@ -306,17 +258,22 @@ export function pickBudgetSource(keyInfo, userInfo) {
306
258
  * 실제로 LiteLLM에 물어보고 캐시를 갱신합니다. detached 자식과
307
259
  * `litellm-budget --refresh` 명령만 호출합니다.
308
260
  * /key/info(키·소속 식별)와 /user/info(팀 멤버십 예산)를 함께 조회합니다.
261
+ *
262
+ * `key` 를 넘기면 resolveKey() 를 다시 부르지 않고 그 값을 그대로 씁니다.
263
+ * 호출자(bin/cli.js 의 --refresh 분기)가 apiKeyHelper 를 한 번만 불러 이
264
+ * 함수와 게이트웨이 모델맵 갱신 양쪽에 같은 키를 재사용할 수 있도록 하는
265
+ * 목적입니다. 어느 경로든 키는 이 함수의 상태 파일에 기록되지 않습니다.
309
266
  */
310
- export async function refreshBudgetState(env = process.env, fetchImpl = fetch) {
267
+ export async function refreshBudgetState(env = process.env, fetchImpl = fetch, key = null) {
311
268
  const base = gatewayBase(env);
312
269
  if (!base) return null;
313
- const key = resolveKey(env);
314
- if (!key) return null;
270
+ const resolvedKey = key || resolveKey(env);
271
+ if (!resolvedKey) return null;
315
272
  const controller = new AbortController();
316
273
  const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
317
274
  const headers = {
318
275
  accept: 'application/json',
319
- authorization: `Bearer ${key}`,
276
+ authorization: `Bearer ${resolvedKey}`,
320
277
  };
321
278
  try {
322
279
  // /key/info 는 커스텀 인증(JWT)을 쓰는 배포에서 404 가 정상입니다. 발급된