claude-token-saver 3.9.2 → 3.10.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.en.md CHANGED
@@ -202,6 +202,24 @@ claude-token-saver route-scan rules # list model-fitting rules (rm
202
202
 
203
203
  Dig deeper: **tier criteria & research evidence** → [docs/TIER_CRITERIA.md](./docs/TIER_CRITERIA.md) (Korean) · **rule-file mechanics, scan triggers, subagent setup** → [docs/ROUTE_SCAN.md](./docs/ROUTE_SCAN.md) (Korean + English)
204
204
 
205
+ ### Behind a gateway (Bedrock / LiteLLM)
206
+
207
+ Through a corporate gateway the transcript records an inference-profile ARN where the model id belongs. That string says nothing about `opus` or `haiku`, so older versions read every session as Sonnet — which made **T1 (→sonnet) rules unreachable and zeroed the savings figures**.
208
+
209
+ Since v3.10.0 the profile id is mapped back to a role (main, opus, sonnet, haiku) and then to the alias your `ANTHROPIC_DEFAULT_*_MODEL` variables declare. The mapping is learned by joining each parent `Task` call to the subagent run it spawned via `toolUseId`. Below three observations, or when the role votes agree less than 80% of the time, the id stays `unknown` and drops out of the delegation aggregate rather than being guessed at.
210
+
211
+ For environments the learner cannot reach, write the mapping yourself in `<userDataDir>/profile-map.json`. Account id and region may be wildcarded:
212
+
213
+ ```jsonc
214
+ {
215
+ "modelAliases": {
216
+ "arn:aws:bedrock:*:*:application-inference-profile/<PROFILE_ID>": "claude-opus-5"
217
+ }
218
+ }
219
+ ```
220
+
221
+ That file holds internal identifiers in plain text — do not commit it. On a direct-API machine it is never created and behaviour is unchanged.
222
+
205
223
  ## Spike issue codes
206
224
 
207
225
  | Code | Meaning |
@@ -282,6 +300,12 @@ Also update `statusLine.command` in `~/.claude/settings.json` to `claude-token-s
282
300
 
283
301
  ## Release notes
284
302
 
303
+ ### v3.10.0 (2026-08-20)
304
+ - **Model tiers are detected again behind a Bedrock / LiteLLM gateway** — when the transcript's model id is an inference-profile ARN there is no `opus` or `haiku` in the string, so it fell back to Sonnet. Since `worthDelegating()` requires `rank > target`, **every T1 rule was rejected**, savings aggregated to zero, and cost was under-counted by roughly 1.67x. The profile id is now learned as a role (parent `Task` call joined to the subagent run by `toolUseId`) and mapped back to the alias your environment declares. The pricing table, the ranks, and the tiering logic are untouched.
305
+ - **No confident mapping means no guess** — under three observations, or below 80% agreement, the id stays `unknown` and leaves the delegation aggregate. Quietly calling it Sonnet was the worse failure.
306
+ - **Manual override** — `modelAliases` in `<userDataDir>/profile-map.json`, wildcards allowed. No profile id or AWS account id is ever hardcoded in this package.
307
+ - Direct-API machines behave **exactly as before** and no new file is written.
308
+
285
309
  ### v3.9.2 (2026-08-01)
286
310
  - **Added a LICENSE file (MIT)** — the field existed in `package.json` but the file did not, which blocked license review for company adoption. It ships in the npm tarball now via `files`.
287
311
  - **Package description and keywords rewritten for what this actually does** — leftover cache-monitoring copy meant it never surfaced for `model-routing` / `delegation` / `subagent`.
package/README.md CHANGED
@@ -182,6 +182,24 @@ claude-token-saver route-scan rules # 등록된 모델 피팅 룰
182
182
 
183
183
  더 알아보기: **티어 기준·리서치 근거** → [docs/TIER_CRITERIA.md](./docs/TIER_CRITERIA.md) · **룰 파일 구조·스캔 트리거·서브에이전트 준비** → [docs/ROUTE_SCAN.md](./docs/ROUTE_SCAN.md)
184
184
 
185
+ ### 게이트웨이(Bedrock·LiteLLM) 경유 환경
186
+
187
+ 사내 게이트웨이를 거치면 로그의 모델명 자리에 추론 프로파일 ARN이 기록됩니다. 그 문자열에는 `opus`·`haiku` 같은 단서가 없어서 예전 버전은 이것을 전부 Sonnet으로 읽었고, 그 결과 **T1(→sonnet) 위임 룰이 하나도 제안되지 않았으며 절감 집계가 0**이었습니다.
188
+
189
+ v3.10.0부터는 프로파일 ID를 역할(main·opus·sonnet·haiku)로 되돌린 뒤 `ANTHROPIC_DEFAULT_*_MODEL` 환경변수가 선언한 별칭으로 치환합니다. 매핑은 부모 세션의 `Task` 호출과 서브에이전트 기록을 `toolUseId`로 조인해 스스로 학습하며, 관측이 3건 미만이거나 역할 판정이 80% 미만으로 갈리면 **추측하지 않고 `unknown`으로 두고 위임 집계에서 제외**합니다.
190
+
191
+ 자동 학습이 닿지 않는 환경을 위한 수동 경로도 있습니다. `<userDataDir>/profile-map.json`에 아래처럼 적으면 되고, 계정 ID와 리전은 `*`로 가려도 매칭됩니다.
192
+
193
+ ```jsonc
194
+ {
195
+ "modelAliases": {
196
+ "arn:aws:bedrock:*:*:application-inference-profile/<PROFILE_ID>": "claude-opus-5"
197
+ }
198
+ }
199
+ ```
200
+
201
+ 이 파일에는 사내 식별자가 평문으로 남으므로 저장소에 커밋하지 마십시오. 게이트웨이를 쓰지 않는 환경에서는 파일이 아예 만들어지지 않고 기존 동작이 그대로 유지됩니다.
202
+
185
203
  ## 토큰 급증 원인 코드
186
204
 
187
205
  | 코드 | 의미 |
@@ -238,6 +256,12 @@ npm uninstall -g claude-cache-monitor && npm i -g claude-token-saver
238
256
 
239
257
  ## 릴리스 노트
240
258
 
259
+ ### v3.10.0 (2026-08-20)
260
+ - **게이트웨이(Bedrock·LiteLLM) 환경에서 모델 티어를 다시 인식합니다** — 로그의 모델명이 추론 프로파일 ARN이면 `opus`·`haiku` 단서가 없어 Sonnet으로 폴백했고, `worthDelegating()`이 `rank > target`을 요구하므로 **T1 위임이 전부 기각**됐습니다. 절감 집계는 0, 비용은 약 1.67배 과소 계상이었습니다. 이제 프로파일 ID를 역할로 학습해(부모 `Task` 호출 ↔ 서브에이전트 `toolUseId` 정확 조인) 환경변수가 선언한 별칭으로 되돌립니다. 가격표·랭크·판정 로직은 그대로입니다.
261
+ - **확신이 없으면 숨기지 않고 드러냅니다** — 관측 3건 미만이거나 역할 동의율 80% 미만이면 `unknown`으로 두고 위임 집계에서 제외합니다. Sonnet으로 조용히 틀리던 기존 동작이 더 나빴습니다.
262
+ - **수동 오버라이드** — `<userDataDir>/profile-map.json`의 `modelAliases`에 와일드카드 패턴으로 직접 지정할 수 있습니다. 프로파일 ID·AWS 계정 ID는 소스에 전혀 넣지 않습니다.
263
+ - 게이트웨이를 쓰지 않는 환경은 **동작이 완전히 동일**합니다(파일도 만들지 않습니다).
264
+
241
265
  ### v3.9.2 (2026-08-01)
242
266
  - **LICENSE 파일 추가 (MIT)** — `package.json`에만 있고 파일이 없어서, 사내 도입 검토 시 라이선스 확인이 막히던 문제. npm 패키지에도 포함되도록 `files`에 넣었습니다.
243
267
  - **패키지 설명·키워드를 현재 기능에 맞게 교체** — 캐시 모니터링 시절 문구가 남아 있어 모델 위임(`model-routing`·`delegation`·`subagent`)으로 검색되지 않았습니다.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-token-saver",
3
- "version": "3.9.2",
3
+ "version": "3.10.0",
4
4
  "description": "Route the easy work your expensive Claude model keeps repeating down to haiku/sonnet — post-hoc session analysis, no realtime router, no extra LLM calls.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cost.js CHANGED
@@ -117,7 +117,22 @@ const TIER_RANK = {
117
117
  'claude-haiku-3': 0,
118
118
  };
119
119
 
120
+ /**
121
+ * True for the explicit 'unknown' marker — an id that could not be resolved
122
+ * at all (see model-alias.js), as opposed to an id this table simply has no
123
+ * entry for. The two must not share a fate: an unresolved gateway id counted
124
+ * as Sonnet silently corrupts every delegation statistic, so it is dropped
125
+ * from the ranking instead of guessed at.
126
+ */
127
+ export function isUnknownModel(model) {
128
+ return !model || String(model).toLowerCase() === 'unknown';
129
+ }
130
+
120
131
  export function modelRank(model) {
132
+ // -1 sits below every real tier, so worthDelegating() rejects it and
133
+ // tierForRank() attributes no saving to it: the run leaves the aggregate
134
+ // rather than distorting it.
135
+ if (isUnknownModel(model)) return -1;
121
136
  const rank = TIER_RANK[detectPricingTier(model)];
122
137
  // Unknown ids fall through detectPricingTier to the Sonnet tier; ranking
123
138
  // them 1 keeps the conservative reading (cheap enough that a Sonnet-target
@@ -0,0 +1,364 @@
1
+ /**
2
+ * model-alias — restore a usable model name when the transcript records a
3
+ * gateway identifier instead of a Claude model id.
4
+ *
5
+ * Why this exists: behind a Bedrock / LiteLLM gateway, `message.model` in the
6
+ * transcript is an inference-profile ARN:
7
+ *
8
+ * converse/arn:aws:bedrock:<region>:<account>:application-inference-profile/<id>
9
+ *
10
+ * Nothing in that string says "opus" or "haiku", so `detectPricingTier()`
11
+ * falls through to its Sonnet default. Everything downstream then reads the
12
+ * session as Sonnet: `worthDelegating('T1', 1)` is false, so every T1 rule is
13
+ * rejected, delegation savings aggregate to zero, and cost is under-counted.
14
+ *
15
+ * The fix is a single normalization point rather than a change to the pricing
16
+ * table — plain aliases (`ap-northeast-2.anthropic.claude-opus-5[1m]`) are
17
+ * already classified correctly, region prefix and `[1m]` suffix included. So
18
+ * all that is missing is ARN → alias.
19
+ *
20
+ * Resolution order, cheapest first:
21
+ * 1. not an ARN → return the input unchanged (direct-API users
22
+ * 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)
26
+ *
27
+ * A profile id is never hardcoded here. Ids differ per account and change
28
+ * with gateway config, and the ARN embeds a 12-digit AWS account id — this
29
+ * package is published to npm, so neither may live in the source.
30
+ */
31
+
32
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, createReadStream } from 'node:fs';
33
+ import { readFile, readdir } from 'node:fs/promises';
34
+ import { createInterface } from 'node:readline';
35
+ import { join, dirname, basename } from 'node:path';
36
+ import { userDataDir, claudeUserDir } from './paths.js';
37
+
38
+ /** Marker returned when a gateway id could not be resolved to a model. */
39
+ export const UNKNOWN_MODEL = 'unknown';
40
+
41
+ /** File holding user overrides plus the learned profile→role votes. */
42
+ export function profileMapPath() {
43
+ return join(userDataDir(), 'profile-map.json');
44
+ }
45
+
46
+ // `converse/` (LiteLLM) or a bare ARN, foundation- or application-scoped.
47
+ const ARN_RE =
48
+ /arn:aws:bedrock:[^:]*:[^:]*:(?:application-)?inference-profile\/([A-Za-z0-9._:-]+)/;
49
+
50
+ /** True when the id came from a Bedrock gateway rather than the Claude API. */
51
+ export function isGatewayModelId(model) {
52
+ return typeof model === 'string' && model.includes('arn:aws:bedrock:');
53
+ }
54
+
55
+ /** The inference-profile id inside an ARN, or null when there is none. */
56
+ export function profileIdFrom(model) {
57
+ if (typeof model !== 'string') return null;
58
+ const m = ARN_RE.exec(model);
59
+ return m ? m[1] : null;
60
+ }
61
+
62
+ /**
63
+ * Roles a profile id can carry. 'main' is the session's own model, which the
64
+ * env declares separately from the per-tier subagent overrides.
65
+ */
66
+ const ROLES = ['main', 'opus', 'sonnet', 'haiku', 'fable'];
67
+
68
+ /**
69
+ * Alias for a role, taken from the environment Claude Code itself uses to
70
+ * pick subagent models. Returns null when the variable is absent or is itself
71
+ * an ARN (resolving an ARN to another ARN would loop).
72
+ */
73
+ export function aliasForRole(role, env = process.env) {
74
+ const candidates = {
75
+ main: [env.ANTHROPIC_MODEL, env.ANTHROPIC_DEFAULT_MODEL, env.ANTHROPIC_DEFAULT_OPUS_MODEL],
76
+ opus: [env.ANTHROPIC_DEFAULT_OPUS_MODEL, env.ANTHROPIC_MODEL],
77
+ sonnet: [env.ANTHROPIC_DEFAULT_SONNET_MODEL],
78
+ haiku: [env.ANTHROPIC_DEFAULT_HAIKU_MODEL],
79
+ fable: [env.ANTHROPIC_DEFAULT_FABLE_MODEL],
80
+ }[role] || [];
81
+ for (const v of candidates) {
82
+ if (typeof v === 'string' && v && !isGatewayModelId(v)) return v;
83
+ }
84
+ return null;
85
+ }
86
+
87
+ // ── override / learned map storage ───────────────────────────────────────
88
+
89
+ let cached = null;
90
+
91
+ /** Read profile-map.json (memoized). Missing or corrupt file → empty map. */
92
+ export function loadProfileMap() {
93
+ if (cached) return cached;
94
+ let data = {};
95
+ try {
96
+ data = JSON.parse(readFileSync(profileMapPath(), 'utf8'));
97
+ } catch { /* absent on first run, and unreadable is not fatal */ }
98
+ cached = {
99
+ version: 1,
100
+ modelAliases: data.modelAliases && typeof data.modelAliases === 'object' ? data.modelAliases : {},
101
+ learned: data.learned && typeof data.learned === 'object' ? data.learned : {},
102
+ learnedAt: data.learnedAt || null,
103
+ scannedSessions: data.scannedSessions || 0,
104
+ };
105
+ return cached;
106
+ }
107
+
108
+ export function saveProfileMap(map) {
109
+ const dir = userDataDir();
110
+ mkdirSync(dir, { recursive: true });
111
+ writeFileSync(profileMapPath(), JSON.stringify(map, null, 2));
112
+ cached = map;
113
+ }
114
+
115
+ /** Drop the memoized map. Tests use this after pointing paths elsewhere. */
116
+ export function resetModelAliasCache() {
117
+ cached = null;
118
+ }
119
+
120
+ /**
121
+ * Glob match for override keys, so a user can write one entry that hides the
122
+ * account id and region: `arn:aws:bedrock:*:*:application-inference-profile/x`.
123
+ */
124
+ function globMatch(pattern, value) {
125
+ const rx = new RegExp(
126
+ '^' + pattern.split('*').map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*') + '$',
127
+ );
128
+ return rx.test(value);
129
+ }
130
+
131
+ function overrideAlias(model, map) {
132
+ for (const [pattern, alias] of Object.entries(map.modelAliases || {})) {
133
+ if (globMatch(pattern, model)) return alias;
134
+ // Overrides are usually written without the LiteLLM `converse/` prefix.
135
+ const bare = model.slice(model.indexOf('arn:aws:bedrock:'));
136
+ if (globMatch(pattern, bare)) return alias;
137
+ }
138
+ return null;
139
+ }
140
+
141
+ // ── resolution ───────────────────────────────────────────────────────────
142
+
143
+ /**
144
+ * Normalize one transcript model id.
145
+ * Non-gateway ids pass through untouched; gateway ids resolve to an alias, or
146
+ * to 'unknown' when the mapping is not confident yet.
147
+ */
148
+ export function resolveModelAlias(rawModel, { env = process.env } = {}) {
149
+ if (!rawModel) return UNKNOWN_MODEL;
150
+ const model = String(rawModel);
151
+ if (!isGatewayModelId(model)) return model;
152
+
153
+ const map = loadProfileMap();
154
+
155
+ const override = overrideAlias(model, map);
156
+ if (override) return override;
157
+
158
+ const pid = profileIdFrom(model);
159
+ if (!pid) return UNKNOWN_MODEL;
160
+
161
+ const entry = map.learned?.[pid];
162
+ if (entry?.role) {
163
+ const alias = aliasForRole(entry.role, env);
164
+ if (alias) return alias;
165
+ }
166
+ return UNKNOWN_MODEL;
167
+ }
168
+
169
+ // ── learning ─────────────────────────────────────────────────────────────
170
+
171
+ // A single observation can be wrong: the time-adjacent parent records leak
172
+ // into a naive join, and a mis-set agent definition mislabels one run. Require
173
+ // a few observations that mostly agree before trusting a mapping.
174
+ export const MIN_VOTES = 3;
175
+ export const MIN_AGREEMENT = 0.8;
176
+
177
+ /** Role named directly by a Task call's `model` parameter. */
178
+ function roleFromModelParam(value) {
179
+ if (typeof value !== 'string') return null;
180
+ const v = value.toLowerCase();
181
+ return ROLES.find((r) => r !== 'main' && v.includes(r)) || null;
182
+ }
183
+
184
+ /** Role declared in a subagent definition's frontmatter (`model: haiku`). */
185
+ function roleFromAgentType(agentType, cache) {
186
+ if (!agentType) return null;
187
+ if (cache.has(agentType)) return cache.get(agentType);
188
+ let role = null;
189
+ for (const dir of [join(claudeUserDir(), 'agents'), join(process.cwd(), '.claude', 'agents')]) {
190
+ const file = join(dir, `${agentType}.md`);
191
+ if (!existsSync(file)) continue;
192
+ try {
193
+ const head = readFileSync(file, 'utf8').slice(0, 2000);
194
+ const m = /^model:\s*([A-Za-z0-9._-]+)/m.exec(head);
195
+ if (m) role = roleFromModelParam(m[1]);
196
+ } catch { /* unreadable definition just yields no vote */ }
197
+ if (role) break;
198
+ }
199
+ cache.set(agentType, role);
200
+ return role;
201
+ }
202
+
203
+ function addVote(votes, pid, role) {
204
+ if (!pid || !role) return;
205
+ const v = (votes[pid] ||= {});
206
+ v[role] = (v[role] || 0) + 1;
207
+ }
208
+
209
+ /**
210
+ * Read one main transcript: which profile id the session itself ran on, and
211
+ * which role each Task/Agent tool_use asked for (joined later by tool_use id).
212
+ */
213
+ async function scanMainTranscript(path, votes, requestedByToolUse, agentTypeCache) {
214
+ let sawGateway = false;
215
+ const rl = createInterface({
216
+ input: createReadStream(path, { encoding: 'utf8' }),
217
+ crlfDelay: Infinity,
218
+ });
219
+ try {
220
+ for await (const line of rl) {
221
+ if (!line.includes('arn:aws:bedrock:') && !line.includes('"Task"') && !line.includes('"Agent"')) {
222
+ continue;
223
+ }
224
+ let entry;
225
+ try {
226
+ entry = JSON.parse(line);
227
+ } catch {
228
+ continue;
229
+ }
230
+ const msg = entry.message;
231
+ if (!msg) continue;
232
+
233
+ // The session's own model: the parent side of the transcript.
234
+ if (msg.model && entry.isSidechain !== true) {
235
+ const pid = profileIdFrom(msg.model);
236
+ if (pid) {
237
+ sawGateway = true;
238
+ addVote(votes, pid, 'main');
239
+ }
240
+ }
241
+
242
+ if (!Array.isArray(msg.content)) continue;
243
+ for (const block of msg.content) {
244
+ if (!block || block.type !== 'tool_use') continue;
245
+ if (block.name !== 'Task' && block.name !== 'Agent') continue;
246
+ const input = block.input || {};
247
+ const role = roleFromModelParam(input.model)
248
+ || roleFromAgentType(input.subagent_type, agentTypeCache);
249
+ if (block.id && role) requestedByToolUse.set(block.id, role);
250
+ }
251
+ }
252
+ } finally {
253
+ rl.close();
254
+ }
255
+ return sawGateway;
256
+ }
257
+
258
+ /** First profile id used by a subagent transcript (a run uses exactly one). */
259
+ async function subagentProfileId(path) {
260
+ const rl = createInterface({
261
+ input: createReadStream(path, { encoding: 'utf8' }),
262
+ crlfDelay: Infinity,
263
+ });
264
+ try {
265
+ for await (const line of rl) {
266
+ if (!line.includes('arn:aws:bedrock:')) continue;
267
+ let entry;
268
+ try {
269
+ entry = JSON.parse(line);
270
+ } catch {
271
+ continue;
272
+ }
273
+ const pid = profileIdFrom(entry.message?.model);
274
+ if (pid) return pid;
275
+ }
276
+ } finally {
277
+ rl.close();
278
+ }
279
+ return null;
280
+ }
281
+
282
+ /** Subagent transcripts a session spawned (mirrors subagent-records layout). */
283
+ async function subagentFiles(sessionPath) {
284
+ const dir = join(dirname(sessionPath), basename(sessionPath, '.jsonl'), 'subagents');
285
+ try {
286
+ return (await readdir(dir)).filter((f) => f.endsWith('.jsonl')).map((f) => join(dir, f));
287
+ } catch {
288
+ return [];
289
+ }
290
+ }
291
+
292
+ /** Decide a role per profile id once the votes are numerous and consistent. */
293
+ export function tallyVotes(votes, { minVotes = MIN_VOTES, minAgreement = MIN_AGREEMENT } = {}) {
294
+ const learned = {};
295
+ for (const [pid, tally] of Object.entries(votes)) {
296
+ const total = Object.values(tally).reduce((a, b) => a + b, 0);
297
+ let role = null;
298
+ let top = 0;
299
+ for (const [r, n] of Object.entries(tally)) {
300
+ if (n > top) { top = n; role = r; }
301
+ }
302
+ const confident = total >= minVotes && top / total >= minAgreement;
303
+ learned[pid] = { role: confident ? role : null, votes: tally, total };
304
+ }
305
+ return learned;
306
+ }
307
+
308
+ /**
309
+ * Learn profile id → role from transcripts and persist the result.
310
+ *
311
+ * The join is exact rather than time-windowed: `.meta.json` carries the
312
+ * `toolUseId` of the Task block that spawned the run, and that block names the
313
+ * model tier. A subagent transcript uses exactly one profile id, so the run's
314
+ * id and the requested role identify each other.
315
+ *
316
+ * @param {object} opts
317
+ * @param {string[]} opts.sessionPaths transcripts to read, newest first
318
+ * @param {number} opts.maxSessions cap on files read (learning is a scan)
319
+ * @returns {Promise<{learned: object, scannedSessions: number, gateway: boolean}>}
320
+ */
321
+ export async function learnProfileMapping({ sessionPaths = [], maxSessions = 40 } = {}) {
322
+ const votes = {};
323
+ const agentTypeCache = new Map();
324
+ let scanned = 0;
325
+ let gateway = false;
326
+
327
+ for (const sessionPath of sessionPaths.slice(0, maxSessions)) {
328
+ const requestedByToolUse = new Map();
329
+ let sawGateway = false;
330
+ try {
331
+ sawGateway = await scanMainTranscript(sessionPath, votes, requestedByToolUse, agentTypeCache);
332
+ } catch {
333
+ continue;
334
+ }
335
+ scanned += 1;
336
+
337
+ for (const jsonl of await subagentFiles(sessionPath)) {
338
+ let meta = null;
339
+ try {
340
+ meta = JSON.parse(await readFile(jsonl.replace(/\.jsonl$/, '.meta.json'), 'utf8'));
341
+ } catch { /* pre-toolUseId runs simply cast no vote */ }
342
+ const role = (meta?.toolUseId && requestedByToolUse.get(meta.toolUseId))
343
+ || roleFromAgentType(meta?.agentType, agentTypeCache);
344
+ if (!role) continue;
345
+ const pid = await subagentProfileId(jsonl);
346
+ if (!pid) continue;
347
+ sawGateway = true;
348
+ addVote(votes, pid, role);
349
+ }
350
+ if (sawGateway) gateway = true;
351
+ }
352
+
353
+ const learned = tallyVotes(votes);
354
+ const map = loadProfileMap();
355
+ const next = {
356
+ ...map,
357
+ learned,
358
+ learnedAt: new Date().toISOString(),
359
+ scannedSessions: scanned,
360
+ };
361
+ // Nothing to record on a non-gateway machine — do not create the file there.
362
+ if (gateway || Object.keys(map.learned || {}).length) saveProfileMap(next);
363
+ return { learned, scannedSessions: scanned, gateway };
364
+ }
package/src/parser.js CHANGED
@@ -4,6 +4,7 @@ import { createInterface } from 'node:readline';
4
4
  import { join, isAbsolute } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
6
  import { loadCache, getCached, putCached, saveCache } from './session-cache.js';
7
+ import { resolveModelAlias } from './model-alias.js';
7
8
 
8
9
  const CLAUDE_DIR = join(homedir(), '.claude', 'projects');
9
10
 
@@ -49,7 +50,7 @@ export async function parseSessionFile(filePath) {
49
50
 
50
51
  requests.set(reqId, {
51
52
  requestId: reqId,
52
- model: msg.model || 'unknown',
53
+ model: resolveModelAlias(msg.model),
53
54
  inputTokens: usage.input_tokens || 0,
54
55
  cacheCreationTokens: usage.cache_creation_input_tokens || 0,
55
56
  cacheReadTokens: usage.cache_read_input_tokens || 0,
package/src/route-scan.js CHANGED
@@ -24,6 +24,7 @@ import { discoverSessionFiles } from './parser.js';
24
24
  import { collectSessionRecords } from './session-records.js';
25
25
  import { collectSubagentRuns, indexRuns, runsForEpisode } from './subagent-records.js';
26
26
  import { estimateCost, modelRank, TIER_TARGET_RANK, tierForRank } from './cost.js';
27
+ import { learnProfileMapping, resetModelAliasCache } from './model-alias.js';
27
28
  import { agentPhrase, agentPhraseEn } from './agents.js';
28
29
 
29
30
  // ── Tier bands (docs/TIER_CRITERIA.md §3) ────────────────────────────────
@@ -331,6 +332,16 @@ export function tierOf(ep, category, th) {
331
332
  export async function runRouteScan({ days = 14 } = {}) {
332
333
  const files = await discoverSessionFiles({ days });
333
334
 
335
+ // Behind a Bedrock/LiteLLM gateway the transcripts carry an inference-profile
336
+ // ARN where the model id belongs, which reads as Sonnet and rejects every T1
337
+ // rule. Refresh the profile→role mapping before parsing so this scan resolves
338
+ // those ids; on a direct-API machine it finds nothing and writes nothing.
339
+ resetModelAliasCache();
340
+ try {
341
+ await learnProfileMapping({ sessionPaths: files.map((f) => f.path) });
342
+ } catch { /* learning is an optimization — the scan still runs without it */ }
343
+ resetModelAliasCache();
344
+
334
345
  // Pass 1 — collect episodes (needed up front: thresholds are calibrated
335
346
  // from the full window's output distribution before any tiering).
336
347
  const all = []; // { ep, projectDir, sessionPath }
@@ -9,10 +9,16 @@
9
9
 
10
10
  import { createReadStream } from 'node:fs';
11
11
  import { createInterface } from 'node:readline';
12
+ import { resolveModelAlias } from './model-alias.js';
12
13
 
13
- /** Strip context-window suffixes like "[1m]" so model ids compare cleanly. */
14
+ /**
15
+ * Strip context-window suffixes like "[1m]" so model ids compare cleanly, and
16
+ * turn a gateway inference-profile ARN back into a Claude alias. Ids that are
17
+ * already Claude aliases pass through untouched.
18
+ */
14
19
  export function normalizeModelId(model) {
15
- return String(model || 'unknown').replace(/\[[^\]]*\]$/, '');
20
+ const resolved = resolveModelAlias(model || 'unknown');
21
+ return String(resolved).replace(/\[[^\]]*\]$/, '');
16
22
  }
17
23
 
18
24
  /** Extract plain text from a Claude transcript message content field. */