sprag-cli 3.46.2 → 3.48.0

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