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.
- package/LICENSE +202 -21
- package/NOTICE +35 -0
- package/README.ko.md +80 -23
- package/README.md +142 -39
- package/bin/cli.js +23 -3
- package/package.json +4 -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
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
|
}
|