sprag-cli 3.46.1 → 3.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
  }