sprag-cli 3.46.2 → 3.48.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,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 가 정상입니다. 발급된
@@ -0,0 +1,264 @@
1
+ /**
2
+ * litellm-models: LiteLLM 게이트웨이의 `/model/info` 로부터 프로파일 ID → 모델
3
+ * 계열 매핑을 가져와 profile-map.json의 gateway 절에 캐시합니다.
4
+ *
5
+ * 왜 이 모듈이 필요한가: model-alias.js는 트랜스크립트의 서브에이전트 실행
6
+ * 기록을 모아 프로파일 ID의 역할(main/opus/sonnet/haiku)을 투표로 추정합니다.
7
+ * 이 학습 경로는 조건이 까다로워서(explicit 3표 이상·80% 동의) 대부분의
8
+ * 사용자에게서 매핑이 비어 있거나 늦게 채워집니다. 반면 LiteLLM
9
+ * `GET /model/info`는 배포마다 litellm_params.model(ARN)과 model_name(계열이
10
+ * 드러나는 이름)을 함께 돌려주므로, 게이트웨이가 이미 알고 있는 매핑을 굳이
11
+ * 투표로 다시 추론할 필요가 없습니다. 이 모듈은 그 지름길을 구현합니다.
12
+ *
13
+ * 왜 키를 인자로만 받는가: apiKeyHelper가 토큰의 수명(TTL)을 소유합니다. 이
14
+ * 모듈이 키를 파일이나 모듈 전역에 사본으로 두면 그 수명과 어긋나게 되므로,
15
+ * 사용자 지시에 따라 이 파일은 resolveKey()를 호출하지 않고 항상 key 인자로
16
+ * 전달받은 값만 요청 헤더에 씁니다. 키는 저장 객체·로그·에러 메시지 어디에도
17
+ * 남기지 않습니다.
18
+ *
19
+ * 왜 렌더 경로가 이 모듈을 기다리지 않는가: litellm-budget.js와 같은 모양으로,
20
+ * 네트워크 호출(refreshGatewayModelMap)은 갱신 경로(사람이 직접 실행하는
21
+ * `sprag profile-map --refresh`, 또는 litellm-budget 갱신에 편승하는
22
+ * maybeRefreshGatewayModelMap)에서만 일어납니다. statusline 렌더는
23
+ * readGatewayModelMap로 캐시 파일만 읽으므로 이 모듈이 fetch를 하는 동안
24
+ * 멈추는 일이 없습니다.
25
+ */
26
+
27
+ import { gatewayBase } from './gateway-auth.js';
28
+ import { loadProfileMap, updateProfileMap, resetModelAliasCache, profileIdFrom, isGatewayModelId } from './model-alias.js';
29
+ import { isRecognizedModelId } from './cost.js';
30
+ import { debug } from './debug.js';
31
+
32
+ /** 게이트웨이 모델 맵의 신선도 기준. 배포 구성은 하루에 여러 번 바뀌지 않습니다. */
33
+ export const MODEL_MAP_TTL_MS = 24 * 60 * 60 * 1000;
34
+
35
+ const FETCH_TIMEOUT_MS = 5000;
36
+
37
+ // litellm_params.model / model_info.base_model 앞에 붙는 공급자 접두어.
38
+ // `bedrock/converse/`가 `bedrock/`의 접두어이기도 하므로, 긴 쪽을 먼저 검사해야
39
+ // `converse/`가 그대로 남는 일이 없습니다.
40
+ const PROVIDER_PREFIXES = ['bedrock/converse/', 'bedrock/', 'openai/', 'vertex_ai/', 'anthropic/'];
41
+
42
+ /**
43
+ * 후보 문자열에서 공급자 접두어와 끝의 대괄호 접미어를 뗍니다.
44
+ *
45
+ * 접미어를 떼는 이유: 트랜스크립트는 컨텍스트 창 표시를 위해
46
+ * `ap-northeast-2.anthropic.claude-opus-5[1m]` 형태로 모델을 기록하지만,
47
+ * `/model/info`가 돌려주는 model_name/litellm_params.model에는 그 접미어가
48
+ * 없습니다. 접미어를 떼지 않으면 같은 모델을 가리키는 두 문자열이 서로 다른
49
+ * 키로 취급되어 별칭 판정이 실패합니다.
50
+ *
51
+ * ARN을 리소스 부분으로 줄이는 이유: ARN 문자열에는 12자리 AWS 계정 ID가 들어
52
+ * 있어서, 그것을 그대로 키나 별칭 값으로 쓰면 계정 ID가 profile-map.json에
53
+ * 기록됩니다. 리소스 부분만 남기면 `foundation-model` ARN은 계열명
54
+ * (`anthropic.claude-opus-5`)으로 줄어 별칭 값으로 쓸 수 있게 되고,
55
+ * `application-inference-profile` ARN은 불투명한 프로파일 ID로 줄어
56
+ * isRecognizedModelId()를 통과하지 못하므로 값이 아니라 키로만 쓰입니다.
57
+ * 줄이지 않으면 isRecognizedModelId()가 계열명을 품은 ARN 전체를 "인식됨"으로
58
+ * 판정해(cost.js의 정규식 한 줄이므로) 계정 ID가 값 쪽으로도 새어 나갑니다.
59
+ */
60
+ function normalizeCandidate(id) {
61
+ if (typeof id !== 'string' || !id) return '';
62
+ let s = id;
63
+ if (isGatewayModelId(s)) return profileIdFrom(s) || '';
64
+ for (const prefix of PROVIDER_PREFIXES) {
65
+ if (s.startsWith(prefix)) {
66
+ s = s.slice(prefix.length);
67
+ break;
68
+ }
69
+ }
70
+ return s.replace(/\[[^\]]*\]$/, '');
71
+ }
72
+
73
+ /**
74
+ * `/model/info` 응답에서 프로파일 ID(또는 계열명 없는 사내 별칭) → 모델 계열
75
+ * 매핑을 뽑아냅니다. 순수 함수입니다: 네트워크·파일 I/O가 없고, 잘못된 입력에도
76
+ * 던지지 않습니다.
77
+ *
78
+ * 항목마다 별칭 후보를 model_name → model_info.base_model →
79
+ * litellm_params.model 순으로 검사해, 정규화 후 isRecognizedModelId()를
80
+ * 통과하는 첫 후보를 그 항목의 별칭으로 채택합니다. 세 후보 모두 인식하지
81
+ * 못하면 아무것도 기록하지 않고 model_name을 skipped에 넣습니다. 추측으로
82
+ * 잘못된 가격표를 매다는 것이 미해석 상태로 남기는 것보다 나쁩니다.
83
+ *
84
+ * 채택된 별칭에 대해 최대 두 개의 키를 기록합니다: (a) litellm_params.model이
85
+ * ARN이면 그 프로파일 ID, (b) model_name 자체가 별칭과 다르면(계열명이 없는
86
+ * 사내 별칭 사례) 정규화한 model_name. 이미 있는 키는 덮어쓰지 않습니다.
87
+ * 같은 프로파일 ID를 여러 배포 항목이 가리키면 첫 항목이 우선합니다.
88
+ *
89
+ * @param {unknown} payload `/model/info` 응답 본문
90
+ * @returns {{aliases: Record<string,string>, skipped: string[], count: number}}
91
+ */
92
+ export function deriveAliasesFromModelInfo(payload) {
93
+ const data = Array.isArray(payload?.data) ? payload.data : null;
94
+ if (!data) return { aliases: {}, skipped: [], count: 0 };
95
+
96
+ const aliases = {};
97
+ const skipped = [];
98
+
99
+ for (const item of data) {
100
+ if (!item || typeof item !== 'object') continue;
101
+ const modelName = typeof item.model_name === 'string' ? item.model_name : '';
102
+ const baseModel = typeof item.model_info?.base_model === 'string' ? item.model_info.base_model : '';
103
+ const rawModel = typeof item.litellm_params?.model === 'string' ? item.litellm_params.model : '';
104
+
105
+ let alias = null;
106
+ for (const candidate of [modelName, baseModel, rawModel]) {
107
+ const normalized = normalizeCandidate(candidate);
108
+ if (normalized && isRecognizedModelId(normalized)) {
109
+ alias = normalized;
110
+ break;
111
+ }
112
+ }
113
+
114
+ if (!alias) {
115
+ if (modelName) skipped.push(modelName);
116
+ continue;
117
+ }
118
+
119
+ // (a) ARN 프로파일 ID. profileIdFrom은 원문(정규화 전) 문자열에서 뽑아야
120
+ // 합니다. 정규화는 공급자 접두어를 떼지만 ARN 본문은 그대로 두므로 원문을
121
+ // 넘겨도 동작하지만, 다른 후보에서 뽑은 alias와 혼동하지 않도록 항상
122
+ // litellm_params.model 원문을 씁니다.
123
+ const pid = profileIdFrom(rawModel);
124
+ if (pid && !(pid in aliases)) aliases[pid] = alias;
125
+
126
+ // (b) 계열명이 없는 사내 별칭(model_name). 이미 별칭 자체로 채택된
127
+ // 경우(정규화한 model_name === alias)는 중복이므로 기록하지 않습니다.
128
+ const normalizedName = normalizeCandidate(modelName);
129
+ if (normalizedName && normalizedName !== alias && !(normalizedName in aliases)) {
130
+ aliases[normalizedName] = alias;
131
+ }
132
+ }
133
+
134
+ return { aliases, skipped, count: data.length };
135
+ }
136
+
137
+ /**
138
+ * profile-map.json의 gateway 절을 읽습니다. base가 현재 gatewayBase(env)와
139
+ * 다르면 다른 게이트웨이의 캐시이므로 null을 돌려줍니다. 예산 캐시(budgetWindow)
140
+ * 와 같은 방식으로 게이트웨이 주소 단위로 격리합니다.
141
+ *
142
+ * @returns {{base:string, fetchedAt:string, aliases:Record<string,string>,
143
+ * skipped:string[], count:number}|null}
144
+ */
145
+ export function readGatewayModelMap(env = process.env) {
146
+ const base = gatewayBase(env);
147
+ if (!base) return null;
148
+ const gateway = loadProfileMap()?.gateway;
149
+ if (!gateway || typeof gateway !== 'object' || gateway.base !== base) return null;
150
+ return gateway;
151
+ }
152
+
153
+ /** base와 신선도(TTL) 기준으로 gateway 절이 오래됐는지 판정합니다 (내부 공용). */
154
+ function isStaleFor(gateway, base, now) {
155
+ if (!base) return true;
156
+ if (!gateway || typeof gateway !== 'object' || gateway.base !== base) return true;
157
+ const fetchedAt = Date.parse(gateway.fetchedAt);
158
+ if (!Number.isFinite(fetchedAt)) return true;
159
+ return now - fetchedAt > MODEL_MAP_TTL_MS;
160
+ }
161
+
162
+ /**
163
+ * gateway 절이 없거나, 다른 게이트웨이 것이거나, fetchedAt이 MODEL_MAP_TTL_MS
164
+ * 보다 오래됐으면 true.
165
+ */
166
+ export function isGatewayModelMapStale(env = process.env, now = Date.now()) {
167
+ const base = gatewayBase(env);
168
+ return isStaleFor(loadProfileMap()?.gateway, base, now);
169
+ }
170
+
171
+ /**
172
+ * LiteLLM `GET {base}/model/info`를 호출해 gateway 절을 갱신합니다.
173
+ *
174
+ * key는 호출자가 이미 확보해 둔 값을 그대로 받습니다. 이 함수는 resolveKey()를
175
+ * 부르지 않고, key가 없으면 즉시 null을 돌려줍니다(헬퍼를 다시 부르지 않도록
176
+ * 하기 위함이며, 헬퍼가 Okta 로그인을 띄우는 배포에서 이 함수가 조용히 그
177
+ * 팝업을 다시 띄우는 일이 없어야 합니다).
178
+ *
179
+ * 실패(비 200·타임아웃·파싱 실패)는 예외를 던지지 않고 debug()로만 남기며,
180
+ * 이 경우 profile-map.json을 건드리지 않습니다. 부분 실패로 기존 gateway
181
+ * 절을 지우면 다음 렌더가 이미 알던 매핑까지 잃습니다.
182
+ *
183
+ * @returns {Promise<{aliases:Record<string,string>, skipped:string[], count:number}|null>}
184
+ */
185
+ export async function refreshGatewayModelMap({ base, key, fetchImpl = fetch } = {}) {
186
+ if (!base || !key) return null;
187
+
188
+ const controller = new AbortController();
189
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
190
+ try {
191
+ let res;
192
+ try {
193
+ res = await fetchImpl(`${base}/model/info`, {
194
+ signal: controller.signal,
195
+ headers: { accept: 'application/json', authorization: `Bearer ${key}` },
196
+ });
197
+ } catch (e) {
198
+ debug('litellm-models:fetch', e);
199
+ return null;
200
+ }
201
+ if (!res.ok) {
202
+ debug('litellm-models:fetch', new Error(`LiteLLM responded ${res.status}`));
203
+ return null;
204
+ }
205
+ let payload;
206
+ try {
207
+ // 응답 본문을 debug로 덤프하지 않습니다. 마스킹되지 않은 필드가 섞여
208
+ // 있을 가능성을 배제할 수 없고, 파싱 실패 로그는 예외 메시지만으로 충분합니다.
209
+ payload = await res.json();
210
+ } catch (e) {
211
+ debug('litellm-models:parse', e);
212
+ return null;
213
+ }
214
+
215
+ const result = deriveAliasesFromModelInfo(payload);
216
+ // 200 응답이라도 본문 형태가 다르면 실패로 취급합니다. deriveAliases…는
217
+ // data가 배열이 아니면 빈 결과를 돌려주는데, 그 빈 결과를 그대로 기록하면
218
+ // 잘 채워져 있던 캐시가 지워집니다. 형태 불일치는 게이트웨이 구성이 바뀐
219
+ // 신호이거나 다른 서비스의 응답이므로, 옛 답을 남겨 두는 편이 안전합니다.
220
+ if (!Array.isArray(payload?.data)) {
221
+ debug('litellm-models:shape', new Error('response has no data array'));
222
+ return null;
223
+ }
224
+ // 이 판정은 디스크의 현재 상태를 봐야 하므로 메모이즈된 값을 쓰지 않습니다.
225
+ resetModelAliasCache();
226
+ const map = loadProfileMap();
227
+ // 별칭을 하나도 못 뽑았는데 기존 맵에는 있었다면 덮어쓰지 않습니다. 키가
228
+ // 사라지는 쪽이 오래된 키를 남겨 두는 쪽보다 나쁘고(해석이 통째로 unknown이
229
+ // 됩니다), 이 상태는 대개 일시적인 구성 오류입니다.
230
+ const had = map.gateway?.base === base ? Object.keys(map.gateway.aliases || {}).length : 0;
231
+ if (Object.keys(result.aliases).length === 0 && had > 0) {
232
+ debug('litellm-models:empty', new Error(`derived 0 aliases, keeping ${had} cached`));
233
+ return null;
234
+ }
235
+ // gateway 절만 갱신합니다. updateProfileMap이 쓰기 직전에 파일을 다시 읽어
236
+ // 병합하므로, 이 네트워크 호출이 오가는 동안 route-scan의 학습 경로가
237
+ // learned를 기록했더라도 그 결과를 지우지 않습니다. 반대 방향도 같습니다.
238
+ updateProfileMap({
239
+ gateway: {
240
+ base,
241
+ fetchedAt: new Date().toISOString(),
242
+ aliases: result.aliases,
243
+ skipped: result.skipped,
244
+ count: result.count,
245
+ },
246
+ });
247
+ return result;
248
+ } finally {
249
+ clearTimeout(timer);
250
+ }
251
+ }
252
+
253
+ /**
254
+ * gateway 절이 stale할 때만 refreshGatewayModelMap을 호출합니다. base/key가
255
+ * 없으면 호출 없이 null.
256
+ *
257
+ * litellm-budget의 --refresh 경로가 이미 얻어 둔 key를 재사용해 이 함수를
258
+ * 호출하므로, apiKeyHelper는 그 갱신 경로 안에서 한 번만 실행됩니다.
259
+ */
260
+ export async function maybeRefreshGatewayModelMap({ base, key, fetchImpl = fetch, now = Date.now() } = {}) {
261
+ if (!base || !key) return null;
262
+ if (!isStaleFor(loadProfileMap()?.gateway, base, now)) return null;
263
+ return refreshGatewayModelMap({ base, key, fetchImpl });
264
+ }