sprag-cli 3.46.2 → 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.
package/README.ko.md CHANGED
@@ -109,6 +109,8 @@ harness 5/5 + ratchet을 실제 적용한 전후 비교입니다 (저자 Claude
109
109
  | [라우터가 아닌 이유](https://github.com/rootstudioyaml/sprag/blob/main/docs/NOT-A-ROUTER.ko.md) | 실시간 라우팅이 캐시를 깨서 비용을 키우는 구조 |
110
110
  | [게이트웨이와 환경](https://github.com/rootstudioyaml/sprag/blob/main/docs/GATEWAYS.ko.md) | Bedrock·Vertex·LiteLLM, 가격표, FAQ, 동작 원리 |
111
111
 
112
+ LiteLLM 게이트웨이 환경에서는 `sprag profile-map --refresh` 가 게이트웨이의 `GET /model/info` 에서 모델 별칭을 바로 읽어 오므로, route-scan 이 위임 투표가 쌓이길 기다리거나 `profile-map.json` 을 손으로 편집할 필요가 없어졌습니다. 이 갱신은 LiteLLM 예산 게이지와 같은 5분 점검·24시간 캐시 주기에 얹혀 돌아갑니다. 인증 토큰은 저장하지 않으며, 호출 시점마다 `apiKeyHelper` 에서 읽어 그 헬퍼의 수명 그대로 만료되게 둡니다. 자세한 내용은 [route-scan](https://github.com/rootstudioyaml/sprag/blob/main/docs/ROUTE_SCAN.md) 문서에 있습니다.
113
+
112
114
  ## 릴리스 노트
113
115
 
114
116
  전체 내역은 [CHANGELOG.md](./CHANGELOG.md)로 옮겼습니다. 최근 변경은 다음과 같습니다.
package/README.md CHANGED
@@ -109,6 +109,8 @@ Every subcommand, flag and the output-language setting: [command reference](http
109
109
  | [Not a router](https://github.com/rootstudioyaml/sprag/blob/main/docs/NOT-A-ROUTER.md) | Why realtime routing breaks the cache and costs more |
110
110
  | [Gateways & environment](https://github.com/rootstudioyaml/sprag/blob/main/docs/GATEWAYS.md) | Bedrock, Vertex, LiteLLM budgets, pricing table, FAQ, how it works |
111
111
 
112
+ Behind a LiteLLM gateway, `sprag profile-map --refresh` now reads model aliases straight from the gateway's own `GET /model/info`, so route-scan no longer needs to wait on learned votes or a hand-edited `profile-map.json`. The refresh rides the same 5-minute check and 24-hour cache as the LiteLLM budget gauge. It never stores your auth token: each refresh reads it from `apiKeyHelper` at call time and lets it expire on the helper's own schedule. Details: [route-scan](https://github.com/rootstudioyaml/sprag/blob/main/docs/ROUTE_SCAN.md).
113
+
112
114
  ## Release notes
113
115
 
114
116
  The full history moved to [CHANGELOG.md](./CHANGELOG.md) (Korean; version headings and command names are language-neutral). Recent changes:
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', 'cohesion',
89
89
  'doc2md', 'harness', 'route-scan', 'compact-window', 'update-check', 'upgrade',
90
- 'seed', 'litellm-budget', 'feedback',
90
+ 'seed', 'litellm-budget', 'profile-map', 'feedback',
91
91
  ]);
92
92
 
93
93
  const USAGE = `sprag — Claude Code token usage, cache health, and model routing
@@ -99,6 +99,7 @@ Usage:
99
99
  sprag --format csv CSV output
100
100
  sprag --project myproj filter by project
101
101
  sprag route-scan detect recurring easy work → delegation candidates
102
+ sprag profile-map show/refresh the gateway model map (LiteLLM /model/info)
102
103
  sprag install set up skill/hooks/statusline
103
104
  sprag install --yes take the defaults without asking
104
105
  sprag uninstall remove everything install added
@@ -296,12 +297,22 @@ async function main() {
296
297
  // sprag litellm-budget --json # 캐시 원본 JSON 출력
297
298
  // sprag litellm-budget --refresh # 지금 프록시에 물어봄 (detached 자식이 사용)
298
299
  if (args[0] === 'litellm-budget') {
299
- const { gatewayBase, readBudgetState, refreshBudgetState, formatBudgetReport } =
300
+ const { gatewayBase, readBudgetState, refreshBudgetState, formatBudgetReport, resolveKey } =
300
301
  await import('../src/litellm-budget.js');
301
302
  const quiet = hasFlag('--quiet');
302
303
  if (hasFlag('--refresh')) {
303
304
  try {
304
- const next = await refreshBudgetState();
305
+ // apiKeyHelper 가 토큰 수명(TTL)을 소유합니다. 헬퍼를 한 번만 불러 그
306
+ // 키를 예산 갱신과 게이트웨이 모델맵 갱신 양쪽에 재사용합니다. 갱신
307
+ // 경로마다 resolveKey() 를 다시 부르면 사본을 만드는 셈이 되고, 헬퍼
308
+ // 호출 횟수도 그만큼 늘어납니다.
309
+ const key = resolveKey();
310
+ let next = null;
311
+ if (key) {
312
+ next = await refreshBudgetState(process.env, fetch, key);
313
+ const { maybeRefreshGatewayModelMap } = await import('../src/litellm-models.js');
314
+ await maybeRefreshGatewayModelMap({ base: gatewayBase(), key });
315
+ }
305
316
  if (!quiet) console.log(JSON.stringify(next, null, 2));
306
317
  } catch (e) {
307
318
  debug('litellm-budget:refresh', e);
@@ -334,6 +345,15 @@ async function main() {
334
345
  return;
335
346
  }
336
347
 
348
+ // Subcommand: profile-map · LiteLLM 게이트웨이의 프로파일 ID → 모델 별칭
349
+ // 매핑을 보여 주거나(GET /model/info) 갱신합니다.
350
+ // sprag profile-map # 캐시된 게이트웨이 모델맵 출력
351
+ // sprag profile-map --json # 캐시 원본 JSON 출력
352
+ // sprag profile-map --refresh # 지금 프록시에 물어봄 (사람이 직접 실행)
353
+ if (args[0] === 'profile-map') {
354
+ return (await import('../src/commands/profile-map.js')).run({ args, hasFlag });
355
+ }
356
+
337
357
  // Subcommand: upgrade — run the install command that matches how this copy
338
358
  // got here, then confirm the new version.
339
359
  // sprag upgrade # install the latest release
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sprag-cli",
3
- "version": "3.46.2",
3
+ "version": "3.47.0",
4
4
  "description": "Sprag - a quality ratchet harness for AI coding agents: model-fitting delegation, ratchet rules, doc2md, Korean style gate. Same tool as claude-token-saver, under its new name.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 가 정상입니다. 발급된
@@ -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
+ }
@@ -18,22 +18,30 @@
18
18
  * all that is missing is ARN → alias.
19
19
  *
20
20
  * Resolution order, cheapest first:
21
- * 1. not an ARN → return the input unchanged (direct-API users
21
+ * 1. user override → `modelAliases` in profile-map.json
22
+ * 2. gateway declaration → `gateway.aliases` in profile-map.json, fetched
23
+ * from the LiteLLM gateway's own /model/info;
24
+ * consulted ahead of learned votes because it is
25
+ * the gateway's own declared mapping, not an
26
+ * inference drawn from transcripts
27
+ * 3. not an ARN → return the input unchanged (direct-API users
22
28
  * must keep their existing behaviour)
23
- * 2. user override → `modelAliases` in profile-map.json
24
- * 3. learned mapping → profile id → role, learned from transcripts
25
- * 4. otherwise → 'unknown' (never a silent Sonnet guess)
29
+ * 4. self-describing id → a profile id that already spells out a family
30
+ * (`foundation-model` ARNs, cross-region ids)
31
+ * 5. learned mapping → profile id → role, learned from transcripts
32
+ * 6. otherwise → 'unknown' (never a silent Sonnet guess)
26
33
  *
27
34
  * A profile id is never hardcoded here. Ids differ per account and change
28
35
  * with gateway config, and the ARN embeds a 12-digit AWS account id — this
29
36
  * package is published to npm, so neither may live in the source.
30
37
  */
31
38
 
32
- import { readFileSync, writeFileSync, mkdirSync, existsSync, createReadStream } from 'node:fs';
39
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, createReadStream, statSync, renameSync } from 'node:fs';
33
40
  import { readFile, readdir } from 'node:fs/promises';
34
41
  import { createInterface } from 'node:readline';
35
42
  import { join, dirname, basename } from 'node:path';
36
43
  import { userDataDir, claudeUserDir } from './paths.js';
44
+ import { gatewayBase } from './gateway-auth.js';
37
45
 
38
46
  /** Marker returned when a gateway id could not be resolved to a model. */
39
47
  export const UNKNOWN_MODEL = 'unknown';
@@ -109,9 +117,19 @@ export function loadProfileMap() {
109
117
  try {
110
118
  data = JSON.parse(readFileSync(profileMapPath(), 'utf8'));
111
119
  } catch { /* absent on first run, and unreadable is not fatal */ }
120
+ // `gateway` is written by `sprag profile-map --refresh` (src/litellm-models.js),
121
+ // never by this module. Shape it defensively: a half-written or
122
+ // hand-edited file must fall back to "no gateway data" rather than throw
123
+ // or feed garbage into gatewayAlias() below.
124
+ const gw = data.gateway;
125
+ const gateway = (gw && typeof gw === 'object'
126
+ && typeof gw.base === 'string' && gw.aliases && typeof gw.aliases === 'object')
127
+ ? { ...gw, skipped: Array.isArray(gw.skipped) ? gw.skipped : [] }
128
+ : null;
112
129
  cached = {
113
130
  version: 1,
114
131
  modelAliases: data.modelAliases && typeof data.modelAliases === 'object' ? data.modelAliases : {},
132
+ gateway,
115
133
  learned: data.learned && typeof data.learned === 'object' ? data.learned : {},
116
134
  learnedAt: data.learnedAt || null,
117
135
  scannedSessions: data.scannedSessions || 0,
@@ -120,10 +138,53 @@ export function loadProfileMap() {
120
138
  }
121
139
 
122
140
  export function saveProfileMap(map) {
141
+ writeProfileMapFile(map);
142
+ cached = map;
143
+ }
144
+
145
+ /**
146
+ * Write the file so a reader never sees a half-written one: serialize to a
147
+ * sibling temp path, then rename, which is atomic within a directory. The old
148
+ * in-place `writeFileSync` could be interrupted mid-write, and `loadProfileMap`
149
+ * treats a corrupt file as an empty map — so a crash at the wrong moment
150
+ * silently discarded the user's hand-written `modelAliases`.
151
+ */
152
+ function writeProfileMapFile(map) {
123
153
  const dir = userDataDir();
124
154
  mkdirSync(dir, { recursive: true });
125
- writeFileSync(profileMapPath(), JSON.stringify(map, null, 2));
126
- cached = map;
155
+ const target = profileMapPath();
156
+ const tmp = `${target}.tmp-${process.pid}`;
157
+ writeFileSync(tmp, JSON.stringify(map, null, 2));
158
+ renameSync(tmp, target);
159
+ }
160
+
161
+ /**
162
+ * Merge `patch`'s top-level keys into whatever is on disk RIGHT NOW, then
163
+ * write.
164
+ *
165
+ * Two producers update this file from separate detached processes, and each
166
+ * owns different keys: route-scan's learner writes `learned`/`learnedAt`/
167
+ * `scannedSessions`, and the gateway refresh writes `gateway`. Both used to
168
+ * spread a snapshot taken when their process started (`{...loadProfileMap()}`)
169
+ * and write the whole document back, so whichever finished last erased the
170
+ * other's section. A scan takes about half a second and the gateway refresh
171
+ * makes a network call inside the same window, so the overlap was not
172
+ * theoretical — and the damage is silent: resolution simply falls back to
173
+ * 'unknown' with nothing in any log to say why.
174
+ *
175
+ * Re-reading immediately before the rename narrows the window from the length
176
+ * of the whole operation to the few microseconds between read and rename. That
177
+ * is not a lock, and a true one would need an O_EXCL lockfile plus stale-lock
178
+ * recovery; the remaining race can only lose a section that was written inside
179
+ * those microseconds, and the next refresh restores it.
180
+ */
181
+ export function updateProfileMap(patch) {
182
+ cached = null;
183
+ const fresh = loadProfileMap();
184
+ const next = { ...fresh, ...patch };
185
+ writeProfileMapFile(next);
186
+ cached = next;
187
+ return next;
127
188
  }
128
189
 
129
190
  /** Drop the memoized map. Tests use this after pointing paths elsewhere. */
@@ -154,6 +215,58 @@ function overrideAlias(model, map) {
154
215
  return null;
155
216
  }
156
217
 
218
+ /**
219
+ * Alias declared by the gateway itself (LiteLLM's `/model/info`, cached
220
+ * under `gateway` in profile-map.json) rather than inferred from transcript
221
+ * votes. Consulted ahead of the learned-vote path for exactly that reason:
222
+ * this is the gateway telling us its own configuration, not a guess built
223
+ * from run counts.
224
+ *
225
+ * Isolated per gateway address the same way the budget cache is (see
226
+ * litellm-budget.js) — a table fetched from one `ANTHROPIC_BASE_URL` must
227
+ * never answer for a different one, so a base mismatch is treated the same
228
+ * as no gateway data at all.
229
+ *
230
+ * Looked up three ways, because the string this function receives is not
231
+ * always the string `/model/info` reported: transcripts append a `[1m]`
232
+ * context-window suffix that the gateway's response never carries, and the
233
+ * input here is sometimes a full ARN where `/model/info` only ever named
234
+ * the bare profile id. Fails quiet throughout — any shape mismatch (no
235
+ * gateway section, a different base, no matching key) returns null and the
236
+ * caller falls through to the resolution steps that existed before this one.
237
+ *
238
+ * Freshness matters as much as the base. This step outranks learned votes
239
+ * because the gateway DECLARES the mapping rather than inferring it, and that
240
+ * argument only holds while the declaration is current. A machine whose
241
+ * refresh has been failing for weeks (permissions revoked, `/model/info` now
242
+ * 403, the proxy moved) would otherwise let a months-old table outrank
243
+ * thousands of accumulating votes forever, which is the opposite of what the
244
+ * ordering was chosen for. Past the grace window the table steps aside and
245
+ * learning answers again; it is not deleted, because the next successful
246
+ * refresh should be able to reuse the file, and `sprag profile-map` still
247
+ * shows it with its age.
248
+ */
249
+ const MODEL_MAP_GRACE_MS = 14 * 24 * 60 * 60 * 1000;
250
+
251
+ function gatewayAlias(model, map, env, now = Date.now()) {
252
+ const gw = map.gateway;
253
+ if (!gw || !gw.aliases || gw.base !== gatewayBase(env)) return null;
254
+ const fetchedAt = Date.parse(gw.fetchedAt);
255
+ if (!Number.isFinite(fetchedAt) || now - fetchedAt > MODEL_MAP_GRACE_MS) return null;
256
+ const bare = model.replace(/\[[^\]]*\]$/, '');
257
+ const pid = profileIdFrom(model);
258
+ // `hasOwn` rather than a bare index: this object comes from JSON.parse, so
259
+ // it carries Object.prototype, and a transcript model id of 'constructor'
260
+ // or 'toString' would otherwise resolve to a function.
261
+ for (const key of [model, bare, pid]) {
262
+ if (key && Object.hasOwn(gw.aliases, key)) {
263
+ const alias = gw.aliases[key];
264
+ if (typeof alias === 'string' && alias) return alias;
265
+ }
266
+ }
267
+ return null;
268
+ }
269
+
157
270
  // ── resolution ───────────────────────────────────────────────────────────
158
271
 
159
272
  /**
@@ -176,6 +289,13 @@ export function resolveModelAlias(rawModel, { env = process.env } = {}) {
176
289
  const override = overrideAlias(model, map);
177
290
  if (override) return override;
178
291
 
292
+ // The gateway's own declared mapping runs next, and — like the override
293
+ // above — for every id, not just ARNs. A house alias with no family name
294
+ // (`prod-large`) is never an ARN, so it would otherwise fall straight
295
+ // through the isGatewayModelId check below to a silent Sonnet default.
296
+ const gatewayHit = gatewayAlias(model, map, env);
297
+ if (gatewayHit) return gatewayHit;
298
+
179
299
  if (!isGatewayModelId(model)) return model;
180
300
 
181
301
  const pid = profileIdFrom(model);
@@ -423,6 +543,37 @@ export function tallyVotes(votes, { minVotes = MIN_VOTES, minAgreement = MIN_AGR
423
543
  return learned;
424
544
  }
425
545
 
546
+ /**
547
+ * Sort transcript paths newest-first, so the `maxSessions` truncation in
548
+ * learnProfileMapping keeps the most RECENT sessions.
549
+ *
550
+ * This used to be a caller contract ("newest first" in the JSDoc), and the
551
+ * only caller broke it: `runRouteScan` hands over `discoverSessionFiles()`
552
+ * output, which parser.js sorts OLDEST first because chronological order is
553
+ * what parsing wants. Learning then read the oldest `maxSessions` files and
554
+ * never saw recent delegations, so a profile that only appears in the last few
555
+ * days stayed unresolved and every run on it dropped out of the aggregate.
556
+ * Measured on one 14-day window: 95 transcripts, of which learning read
557
+ * 09-04 through 09-15 and discarded the three most recent days — including the
558
+ * sessions holding the haiku evidence it needed.
559
+ *
560
+ * Sorting here rather than at the call site puts the invariant in the same
561
+ * function as the truncation that depends on it, so a future caller cannot
562
+ * reintroduce the bug. Cost is one stat() per candidate path, against a scan
563
+ * that already stats every file to build the window.
564
+ */
565
+ function newestFirst(paths) {
566
+ return paths
567
+ .map((p) => {
568
+ let mtime = 0;
569
+ // An unreadable path sorts last and then fails harmlessly in the loop.
570
+ try { mtime = statSync(p).mtimeMs; } catch { /* keep 0 */ }
571
+ return { p, mtime };
572
+ })
573
+ .sort((a, b) => b.mtime - a.mtime)
574
+ .map((e) => e.p);
575
+ }
576
+
426
577
  /**
427
578
  * Learn profile id → role from transcripts and persist the result.
428
579
  *
@@ -432,7 +583,8 @@ export function tallyVotes(votes, { minVotes = MIN_VOTES, minAgreement = MIN_AGR
432
583
  * id and the requested role identify each other.
433
584
  *
434
585
  * @param {object} opts
435
- * @param {string[]} opts.sessionPaths transcripts to read, newest first
586
+ * @param {string[]} opts.sessionPaths transcripts to read, in any order — they
587
+ * are sorted newest-first here, see below
436
588
  * @param {number} opts.maxSessions cap on files read (learning is a scan)
437
589
  * @returns {Promise<{learned: object, scannedSessions: number, gateway: boolean}>}
438
590
  */
@@ -442,7 +594,7 @@ export async function learnProfileMapping({ sessionPaths = [], maxSessions = 40
442
594
  let scanned = 0;
443
595
  let gateway = false;
444
596
 
445
- for (const sessionPath of sessionPaths.slice(0, maxSessions)) {
597
+ for (const sessionPath of newestFirst(sessionPaths).slice(0, maxSessions)) {
446
598
  const requestedByToolUse = new Map();
447
599
  let sawGateway = false;
448
600
  try {
@@ -472,13 +624,15 @@ export async function learnProfileMapping({ sessionPaths = [], maxSessions = 40
472
624
 
473
625
  const learned = tallyVotes(votes);
474
626
  const map = loadProfileMap();
475
- const next = {
476
- ...map,
477
- learned,
478
- learnedAt: new Date().toISOString(),
479
- scannedSessions: scanned,
480
- };
481
627
  // Nothing to record on a non-gateway machine — do not create the file there.
482
- if (gateway || Object.keys(map.learned || {}).length) saveProfileMap(next);
628
+ // Writes only this learner's own keys, so a gateway refresh that landed while
629
+ // this scan was running keeps its section (see updateProfileMap).
630
+ if (gateway || Object.keys(map.learned || {}).length) {
631
+ updateProfileMap({
632
+ learned,
633
+ learnedAt: new Date().toISOString(),
634
+ scannedSessions: scanned,
635
+ });
636
+ }
483
637
  return { learned, scannedSessions: scanned, gateway };
484
638
  }