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.
- package/README.ko.md +25 -31
- package/README.md +34 -185
- package/bin/cli.js +23 -3
- package/package.json +1 -3
- package/src/commands/profile-map.js +164 -0
- package/src/commands/route-scan.js +11 -7
- package/src/gateway-auth.js +86 -0
- package/src/litellm-budget.js +22 -65
- package/src/litellm-models.js +264 -0
- package/src/model-alias.js +170 -16
|
@@ -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
|
+
}
|
package/src/model-alias.js
CHANGED
|
@@ -18,22 +18,30 @@
|
|
|
18
18
|
* all that is missing is ARN → alias.
|
|
19
19
|
*
|
|
20
20
|
* Resolution order, cheapest first:
|
|
21
|
-
* 1.
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
|
|
126
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
}
|