claude-token-saver 3.9.1 → 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/LICENSE +21 -0
- package/README.en.md +47 -0
- package/README.md +46 -0
- package/package.json +14 -9
- package/src/commands/route-scan.js +3 -0
- package/src/cost.js +15 -0
- package/src/first-run-note.js +63 -0
- package/src/model-alias.js +364 -0
- package/src/parser.js +2 -1
- package/src/route-scan.js +11 -0
- package/src/session-records.js +8 -2
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 rootstudioyaml
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.en.md
CHANGED
|
@@ -17,6 +17,23 @@ npm i -g claude-token-saver # postinstall auto-registers the statusline + Skil
|
|
|
17
17
|
|
|
18
18
|

|
|
19
19
|
|
|
20
|
+
## 📺 Came here from the video? — 60 seconds
|
|
21
|
+
|
|
22
|
+
This is not a router. It never intercepts a request in realtime.
|
|
23
|
+
**After a session ends** it reads your local logs, finds the easy patterns your expensive model
|
|
24
|
+
kept handling, and promotes them into rules so a cheaper model takes them **from the next session
|
|
25
|
+
onward**. Rules are scoped global or per-project.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm i -g claude-token-saver@latest
|
|
29
|
+
claude-token-saver route-scan # find delegation candidates in your own history (0 LLM calls)
|
|
30
|
+
claude-token-saver route-scan rules # list promoted rules · rm <N> to remove
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Thresholds come from **your own last-14-day distribution (p25/p75)**, not someone else's benchmark.
|
|
34
|
+
Measured rule-health — whether a delegated run actually succeeded — landed in [v3.9.0](#v390-2026-08-01).
|
|
35
|
+
|
|
36
|
+
|
|
20
37
|
## ⚡ Why — the 30-second pitch
|
|
21
38
|
|
|
22
39
|
| | |
|
|
@@ -185,6 +202,24 @@ claude-token-saver route-scan rules # list model-fitting rules (rm
|
|
|
185
202
|
|
|
186
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)
|
|
187
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
|
+
|
|
188
223
|
## Spike issue codes
|
|
189
224
|
|
|
190
225
|
| Code | Meaning |
|
|
@@ -265,6 +300,18 @@ Also update `statusLine.command` in `~/.claude/settings.json` to `claude-token-s
|
|
|
265
300
|
|
|
266
301
|
## Release notes
|
|
267
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
|
+
|
|
309
|
+
### v3.9.2 (2026-08-01)
|
|
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`.
|
|
311
|
+
- **Package description and keywords rewritten for what this actually does** — leftover cache-monitoring copy meant it never surfaced for `model-routing` / `delegation` / `subagent`.
|
|
312
|
+
- **A 60-second on-ramp at the top of the README** — that this is post-hoc analysis rather than a router, plus the three commands from install to seeing your own numbers.
|
|
313
|
+
- **One-time note in `route-scan`** — prints the explainer link exactly once. Disable with `CTS_NO_NOTE=1`.
|
|
314
|
+
|
|
268
315
|
### v3.9.1 (2026-08-01)
|
|
269
316
|
- **compact-window now recommends a 400k–700k band instead of a single 400k** — 400k proved too tight in practice and compacted too often. The advice is a range now, and **a window inside it (or below it) is never warned about**; only an unset value or one above 700k raises `🅷⚠ compact-window?` and the briefing. `set` defaults to 500k (mid-band); pick your own with `--value 600k`.
|
|
270
317
|
|
package/README.md
CHANGED
|
@@ -17,6 +17,22 @@ npm i -g claude-token-saver # postinstall이 statusline + Skill 자동 등록
|
|
|
17
17
|
|
|
18
18
|

|
|
19
19
|
|
|
20
|
+
## 📺 영상 보고 오셨다면 — 60초
|
|
21
|
+
|
|
22
|
+
라우터가 아닙니다. 요청을 실시간으로 가로채지 않습니다.
|
|
23
|
+
**세션이 끝난 뒤** 로컬 기록을 읽어서, 비싼 모델이 반복해서 처리해 온 쉬운 유형을 뽑고,
|
|
24
|
+
그 유형은 **다음 세션부터** 싼 모델이 맡도록 룰로 겁니다. 룰은 글로벌·프로젝트로 범위가 나뉩니다.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm i -g claude-token-saver@latest
|
|
28
|
+
claude-token-saver route-scan # 내 지난 세션에서 위임 후보 뽑기 (LLM 호출 0)
|
|
29
|
+
claude-token-saver route-scan rules # 승격된 룰 확인 · rm <N> 으로 삭제
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
기준선은 남의 벤치마크가 아니라 **내 최근 14일 분포(p25/p75)** 로 잡습니다.
|
|
33
|
+
넘긴 뒤 실제로 잘 됐는지까지 재는 실측 rule-health는 [v3.9.0](#v390-2026-08-01)에 들어갔습니다.
|
|
34
|
+
|
|
35
|
+
|
|
20
36
|
## ⚡ 왜 쓰나 — 30초 요약
|
|
21
37
|
|
|
22
38
|
| | |
|
|
@@ -166,6 +182,24 @@ claude-token-saver route-scan rules # 등록된 모델 피팅 룰
|
|
|
166
182
|
|
|
167
183
|
더 알아보기: **티어 기준·리서치 근거** → [docs/TIER_CRITERIA.md](./docs/TIER_CRITERIA.md) · **룰 파일 구조·스캔 트리거·서브에이전트 준비** → [docs/ROUTE_SCAN.md](./docs/ROUTE_SCAN.md)
|
|
168
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
|
+
|
|
169
203
|
## 토큰 급증 원인 코드
|
|
170
204
|
|
|
171
205
|
| 코드 | 의미 |
|
|
@@ -222,6 +256,18 @@ npm uninstall -g claude-cache-monitor && npm i -g claude-token-saver
|
|
|
222
256
|
|
|
223
257
|
## 릴리스 노트
|
|
224
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
|
+
|
|
265
|
+
### v3.9.2 (2026-08-01)
|
|
266
|
+
- **LICENSE 파일 추가 (MIT)** — `package.json`에만 있고 파일이 없어서, 사내 도입 검토 시 라이선스 확인이 막히던 문제. npm 패키지에도 포함되도록 `files`에 넣었습니다.
|
|
267
|
+
- **패키지 설명·키워드를 현재 기능에 맞게 교체** — 캐시 모니터링 시절 문구가 남아 있어 모델 위임(`model-routing`·`delegation`·`subagent`)으로 검색되지 않았습니다.
|
|
268
|
+
- **README 상단에 60초 진입로** — 라우터가 아니라 사후 분석이라는 점과, 설치부터 내 숫자 확인까지의 명령 3줄.
|
|
269
|
+
- **`route-scan` 최초 1회 안내** — 기능 설명 영상 링크를 딱 한 번만 출력합니다. `CTS_NO_NOTE=1`로 끕니다.
|
|
270
|
+
|
|
225
271
|
### v3.9.1 (2026-08-01)
|
|
226
272
|
- **compact-window 권장값이 단일 40만에서 40만~70만 범위로** — 40만은 실사용에서 너무 빡빡해 압축이 잦았습니다. 이제 범위를 제안하고, **그 안(또는 그보다 낮게) 잡아둔 세션은 경고하지 않습니다.** 미설정이거나 70만 초과일 때만 `🅷⚠ compact-window?`와 브리핑이 뜹니다. `set`의 기본값도 범위 중간인 50만으로 올렸고, 원하는 값은 `--value 600k`로 지정합니다.
|
|
227
273
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-token-saver",
|
|
3
|
-
"version": "3.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "3.10.0",
|
|
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": {
|
|
7
7
|
"claude-token-saver": "bin/cli.js"
|
|
@@ -16,22 +16,27 @@
|
|
|
16
16
|
"presets/",
|
|
17
17
|
"examples/",
|
|
18
18
|
"README.md",
|
|
19
|
-
"README.en.md"
|
|
19
|
+
"README.en.md",
|
|
20
|
+
"LICENSE"
|
|
20
21
|
],
|
|
21
22
|
"engines": {
|
|
22
23
|
"node": ">=18"
|
|
23
24
|
},
|
|
24
25
|
"keywords": [
|
|
25
26
|
"claude",
|
|
27
|
+
"claude-code",
|
|
26
28
|
"anthropic",
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
+
"model-routing",
|
|
30
|
+
"llm-router",
|
|
31
|
+
"delegation",
|
|
32
|
+
"subagent",
|
|
33
|
+
"haiku",
|
|
34
|
+
"cost-savings",
|
|
35
|
+
"token-usage",
|
|
29
36
|
"prompt-caching",
|
|
30
|
-
"
|
|
37
|
+
"cache",
|
|
31
38
|
"statusline",
|
|
32
|
-
"
|
|
33
|
-
"token-usage",
|
|
34
|
-
"cost-savings",
|
|
39
|
+
"cli",
|
|
35
40
|
"1m-context"
|
|
36
41
|
],
|
|
37
42
|
"license": "MIT",
|
|
@@ -202,6 +202,7 @@ export async function run({ args, hasFlag, numArg }) {
|
|
|
202
202
|
console.log(lang === 'ko'
|
|
203
203
|
? '위임 후보 없음 (반복 3회 미만이거나 이미 처리됨).'
|
|
204
204
|
: 'No delegation candidates (below recurrence threshold or already resolved).');
|
|
205
|
+
(await import('../first-run-note.js')).printOnce('route-scan', lang);
|
|
205
206
|
return;
|
|
206
207
|
}
|
|
207
208
|
console.log(lang === 'ko' ? '\n위임 후보 (R<N>=후보 번호, T2/T1=난이도 등급):' : '\nDelegation candidates (R<N> = candidate id, T2/T1 = difficulty tier):');
|
|
@@ -223,5 +224,7 @@ export async function run({ args, hasFlag, numArg }) {
|
|
|
223
224
|
console.log(lang === 'ko' ? '등록 / 무시:' : 'Promote / dismiss:');
|
|
224
225
|
console.log(' claude-token-saver harness promote R<N> --project|--global');
|
|
225
226
|
console.log(' claude-token-saver route-scan dismiss <N>');
|
|
227
|
+
// 최초 1회만 — 매번 찍으면 도구가 광고판이 된다 (CTS_NO_NOTE=1 로 끔)
|
|
228
|
+
(await import('../first-run-note.js')).printOnce('route-scan', lang);
|
|
226
229
|
return;
|
|
227
230
|
}
|
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,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* first-run-note — a one-time pointer to where a feature is explained.
|
|
3
|
+
*
|
|
4
|
+
* A CLI that advertises on every invocation stops being a tool, so this fires
|
|
5
|
+
* ONCE per note key and then never again: the shown-at timestamp is persisted
|
|
6
|
+
* next to the other state files and checked before anything is printed.
|
|
7
|
+
*
|
|
8
|
+
* Opt out entirely with CTS_NO_NOTE=1 (also honoured by anything that pipes
|
|
9
|
+
* our output somewhere it does not belong).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { userDataDir } from './paths.js';
|
|
15
|
+
|
|
16
|
+
export function firstRunStatePath() {
|
|
17
|
+
return join(userDataDir(), 'first-run.json');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function load() {
|
|
21
|
+
try {
|
|
22
|
+
const s = JSON.parse(readFileSync(firstRunStatePath(), 'utf8'));
|
|
23
|
+
return s && typeof s === 'object' ? s : {};
|
|
24
|
+
} catch {
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function markShown(key, now) {
|
|
30
|
+
const state = load();
|
|
31
|
+
state[key] = now;
|
|
32
|
+
const dir = userDataDir();
|
|
33
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
34
|
+
writeFileSync(firstRunStatePath(), JSON.stringify(state) + '\n');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** True only the first time this key is asked about. Best-effort — a
|
|
38
|
+
* read-only state dir just means the note repeats, never that we crash. */
|
|
39
|
+
export function shouldShowOnce(key, { now = Date.now() } = {}) {
|
|
40
|
+
if (process.env.CTS_NO_NOTE === '1') return false;
|
|
41
|
+
if (load()[key]) return false;
|
|
42
|
+
try {
|
|
43
|
+
markShown(key, now);
|
|
44
|
+
} catch {
|
|
45
|
+
/* state dir unwritable — show it, do not fail the command */
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const NOTES = {
|
|
51
|
+
'route-scan': {
|
|
52
|
+
ko: '📺 이 기능을 설명한 영상: https://www.youtube.com/@DeepPulseKR (이 안내는 한 번만 표시됩니다)',
|
|
53
|
+
en: '📺 How this works, in 3 minutes: https://www.youtube.com/@DeepPulseEN (shown once)',
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Print the one-time note for `key`, or nothing. */
|
|
58
|
+
export function printOnce(key, lang = 'en') {
|
|
59
|
+
const note = NOTES[key];
|
|
60
|
+
if (!note || !shouldShowOnce(key)) return;
|
|
61
|
+
console.log('');
|
|
62
|
+
console.log(lang === 'ko' ? note.ko : note.en);
|
|
63
|
+
}
|
|
@@ -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
|
|
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 }
|
package/src/session-records.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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. */
|