claude-usage-limits 1.40.4 → 1.40.5

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "usage-limits",
3
3
  "displayName": "Usage Limits",
4
- "version": "1.40.4",
4
+ "version": "1.40.5",
5
5
  "description": "Puts your remaining Claude Code usage limit into Claude's context before every prompt, so it opens with what fits in the budget instead of starting work that gets cut off. Reports headroom as turns rather than percentages, prices a job before you start it, and detects your plan tier.",
6
6
  "author": {
7
7
  "name": "Ridelink",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "usage-limits",
3
- "version": "1.40.4",
3
+ "version": "1.40.5",
4
4
  "description": "Reports how much of your Codex usage limit is left as turns of work rather than a percentage, prices a job before you start it, and counts the other agents sharing the same budget.",
5
5
  "author": {
6
6
  "name": "Ridelink",
package/README.md CHANGED
@@ -1120,7 +1120,23 @@ There are two separate things people mean by "change the model":
1120
1120
 
1121
1121
  `claude-usage-limits mode --baseline` shows both side by side. The budget line
1122
1122
  now says the tier as well, and where the reading came from, because the number
1123
- that decides what a turn costs was the one number the line never printed.
1123
+ that decides what a turn costs was the one number the line never printed. It
1124
+ names the version, not only the family (`opus 5.5/xhigh`, not `opus/xhigh`),
1125
+ read from the newest assistant message in the session's transcript: a settings
1126
+ alias such as `opus` cannot say which Opus answered.
1127
+
1128
+ When the model running is an older release of a family that has a newer one at
1129
+ a lower price - Opus 5 or 4.8 against Opus 5.5 ($4/$20, cache reads $0.20
1130
+ against $0.50), Fable 5 against Fable 5.1 (reads $0.25 against $1) - the brief
1131
+ says so once per session, with the prices and how many turns the one-off cache
1132
+ rebuild takes to repay. In Claude Code it offers `/model <id>`, which switches
1133
+ the session and saves the model as the default for new sessions; elsewhere it
1134
+ offers the host's own model setting and names no slash command. A relay whose
1135
+ own `model` is pinned to the older release is named too, since a wake starts
1136
+ with that `--model` whatever the session switched to. Releases on
1137
+ either side of the 4.7 tokenizer change are not compared, because a price per
1138
+ token is not like for like across it. `mode --no-advice` mutes this with the
1139
+ rest of the advice.
1124
1140
 
1125
1141
  What Claude can genuinely move, stated without embroidery: the model on an
1126
1142
  `Agent` call, and the model and effort inside a `Workflow` script. Its own
package/commands/relay.md CHANGED
@@ -29,6 +29,9 @@ The rest:
29
29
  (or whichever mode you want) as well: a resume does **not** inherit the
30
30
  session's permission mode, so without one it will sit waiting for an
31
31
  approval nobody is there to give. `show off` makes it a headless run instead.
32
+ - `model <id>` - the `--model` a resumed run starts with; `model` alone goes
33
+ back to the default. It is separate from `/model` in the session, so a wake
34
+ pinned to an older model stays on it until this is changed.
32
35
  - `voice on|off` - carry how you write in the hand-off, so the resumed session
33
36
  answers in your voice without being reminded. On by default.
34
37
  - `bugcheck on|always|off` - the hand-off asks for two bug passes before anything
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-usage-limits",
3
- "version": "1.40.4",
3
+ "version": "1.40.5",
4
4
  "description": "Puts your remaining Claude Code usage limit into Claude's context before every prompt, so it opens with what fits in the budget instead of starting work that gets cut off. Reports headroom as turns rather than percentages, prices a job before you start it, and detects your plan tier.",
5
5
  "keywords": [
6
6
  "claude",
@@ -256,8 +256,9 @@ A bracketed suffix on a model id (`claude-sonnet-5[1m]`) is stripped before
256
256
  the lookup: it marks a context-window variant of the same model, not a new
257
257
  one. Cache reads price at a tenth of the input rate unless a row carries a
258
258
  `cacheRead` figure of its own - Fable and Mythos 5.1 price reads outright at
259
- $0.25 per million, far under the tenth rule, and reads are the dominant input
260
- in exactly the long sessions where the difference matters.
259
+ $0.25 per million (0.025x input) and Opus 5.5 at $0.20 (0.05x its $4 input),
260
+ all under the tenth rule, and reads are the dominant input in exactly the long
261
+ sessions where the difference matters.
261
262
 
262
263
  Until someone does, a model this table has not seen is priced at the average of
263
264
  the family its name contains: an unreleased `claude-opus-5-2` is charged at the
@@ -160,7 +160,7 @@ function readSaid() {
160
160
  }
161
161
  }
162
162
  function keepSaidFor(key) {
163
- return /#(standing|cachemiss|stale|relaylast)$/.test(key) ? 24 * 60 * 60 * 1000 : 60 * 60 * 1000;
163
+ return /#(standing|cachemiss|stale|relaylast|newer)$/.test(key) ? 24 * 60 * 60 * 1000 : 60 * 60 * 1000;
164
164
  }
165
165
 
166
166
  // A plugin update takes effect when Claude Code restarts, so a session that
@@ -202,6 +202,30 @@ function staleVersionFor(sessionId, now, dir) {
202
202
  return 'usage-limits ' + installed + ' is installed but this session still runs ' + running +
203
203
  ', because a plugin update applies at the next start; a relay or cap set here follows the older rules until then.';
204
204
  }
205
+ // A newer model of the same family at a lower price, said once a session.
206
+ // Once is the rule every recommendation here keeps: the fact does not change
207
+ // from prompt to prompt, and repeating it would be the plugin charging for its
208
+ // own presence. Keyed on the pair, so a session that moves to a different model
209
+ // with its own newer sibling hears about that one.
210
+ function newerModelFor(sessionId, advice, now) {
211
+ if (!advice || !advice.text) return null;
212
+ const at = Number.isFinite(now) ? now : Date.now();
213
+ const key = String(sessionId || '_') + '#newer';
214
+ const all = readSaid();
215
+ const entry = all[key];
216
+ if (entry && entry.seen === advice.id && Number.isFinite(entry.at) && at - entry.at < keepSaidFor(key)) return null;
217
+ all[key] = { at, seen: advice.id };
218
+ writeSaid(all, at);
219
+ return advice.text;
220
+ }
221
+ // The model the relay resumes with, when one is set: wake.js passes it as --model.
222
+ function relayModelNow() {
223
+ try {
224
+ return relay.settings(relay.read()).model || null;
225
+ } catch (err) {
226
+ return null;
227
+ }
228
+ }
205
229
  // How the last relay ended is news once. It used to ride along for six hours
206
230
  // after any relay ended, on every prompt of every session: on 2026-09-22 a wake
207
231
  // lost at 5:53 AM was repeated in two sessions' briefs all afternoon, about
@@ -784,6 +808,7 @@ function briefText(input) {
784
808
  if (parts.tier) sentences.push(parts.tier);
785
809
  // Once per session, and only when the installed version is not this one.
786
810
  if (parts.staleVersion) sentences.push(parts.staleVersion);
811
+ if (parts.newerModel) sentences.push(parts.newerModel);
787
812
  const bounded = mode.boundsNote(bounds);
788
813
  if (bounded) sentences.push(bounded);
789
814
  if (parts.planChanged) {
@@ -1403,7 +1428,7 @@ function briefText(input) {
1403
1428
  // The one recommendation this session is allowed, in its short form. It
1404
1429
  // still cites the measurement, still names the command: terse is fewer
1405
1430
  // words, not less evidence.
1406
- const adviceText = parts.adviceText ? ' ' + parts.adviceText : '';
1431
+ const adviceText = (parts.adviceText ? ' ' + parts.adviceText : '') + (parts.newerModel ? ' ' + parts.newerModel : '');
1407
1432
  return (
1408
1433
  sentences[0] + caveat + (parts.tier ? ' ' + parts.tier : '') + (bounded ? ' ' + bounded : '') + adviceText +
1409
1434
  (escapeSentence ? ' ' + escapeSentence : '') +
@@ -1904,7 +1929,10 @@ async function run(now, hookInput, opts) {
1904
1929
  // What tier is producing this turn, and what the user's own baseline is.
1905
1930
  // Read, displayed, never written.
1906
1931
  const terse = budget.policy.briefStyle === 'terse';
1907
- const tier = mode.tierLine(mode.tierNow({ sessionId, now, usage, env: process.env }), { terse });
1932
+ const tierReading = mode.tierNow({ sessionId, now, usage, env: process.env, transcriptPath: hookInput && hookInput.transcript_path });
1933
+ const tier = mode.tierLine(tierReading, { terse });
1934
+ // Muted advice is muted for this too: it is a recommendation like the others.
1935
+ const newerAdvice = budget.advice && budget.advice.off ? null : mode.newerModelAdvice(tierReading, { usage, host: usage.currentHost(), bounds: budget.bounds, relayModel: relayModelNow() });
1908
1936
 
1909
1937
  // The recommendation channel. The measured fit sentence IS the
1910
1938
  // recommendation - it cites this account's own numbers and names the exact
@@ -1959,6 +1987,7 @@ async function run(now, hookInput, opts) {
1959
1987
  standingShort,
1960
1988
  cacheMissWhy: cacheMissWhyFor(sessionId, now),
1961
1989
  staleVersion: staleVersionFor(sessionId, now),
1990
+ newerModel: newerModelFor(sessionId, newerAdvice, now),
1962
1991
  adviceText: terse && offering ? advice.text : null,
1963
1992
  relay: carry,
1964
1993
  voiceNote,
@@ -2065,7 +2094,7 @@ function withBugcheck(text) {
2065
2094
  return text;
2066
2095
  }
2067
2096
 
2068
- module.exports = { wallFeatures, sweepDebris, withBugcheck, sayOnce, shapeOf, saidFile, REPEAT_MS, staleVersionFor, relayNewsFor, installedVersion, runningVersion,
2097
+ module.exports = { wallFeatures, sweepDebris, withBugcheck, sayOnce, shapeOf, saidFile, REPEAT_MS, staleVersionFor, newerModelFor, relayNewsFor, installedVersion, runningVersion,
2069
2098
  readSaid, standingSaid, markStanding, standingShortFor, STANDING_SHORT, cacheMissWhyFor, missReason, MISS_RECENT_MS,
2070
2099
  DEFAULTS,
2071
2100
  HOOK_BUDGET_MS,
@@ -1057,9 +1057,15 @@ function tierNow(options) {
1057
1057
  settings = null;
1058
1058
  }
1059
1059
 
1060
+ // The newest assistant message is the model that actually answered, which a
1061
+ // settings alias ('opus') cannot say. The hook's own transcript_path is read
1062
+ // first when the caller has it: it is the file this very turn is written to,
1063
+ // where the session-id lookup has to find it under the config directory.
1060
1064
  let running = null;
1061
1065
  try {
1062
- const seen = sessionId ? usage.liveModel(sessionId) : null;
1066
+ let seen = null;
1067
+ if (opts.transcriptPath && typeof usage.transcriptModel === 'function') seen = usage.transcriptModel(opts.transcriptPath);
1068
+ if (!(seen && seen.model) && sessionId) seen = usage.liveModel(sessionId);
1063
1069
  running = seen && seen.model ? seen.model : null;
1064
1070
  } catch (err) {
1065
1071
  running = null;
@@ -1112,6 +1118,150 @@ function sameFamily(a, b) {
1112
1118
  return left === right;
1113
1119
  }
1114
1120
 
1121
+ // The model as the brief names it: the family and the version. It used to be
1122
+ // the family alone, so claude-opus-5 and claude-opus-5-5 both printed "opus"
1123
+ // and a switch between them - a 20% price change, 60% on cache reads - was
1124
+ // invisible in the one line that exists to say what is producing the turn. A
1125
+ // bare alias ('opus') has no version to show and is shown as it is.
1126
+ function modelLabel(name) {
1127
+ if (!name) return null;
1128
+ let parsed = null;
1129
+ try {
1130
+ parsed = require('./usage.js').parseModelId(name);
1131
+ } catch (err) {
1132
+ parsed = null;
1133
+ }
1134
+ if (parsed) return parsed.version.length ? parsed.family + ' ' + parsed.version.join('.') : parsed.family;
1135
+ const rank = modelRank(name);
1136
+ return rank === null ? name : MODEL_ORDER[rank];
1137
+ }
1138
+
1139
+ // Same model for the purpose of "is the running tier the baseline". Same
1140
+ // family, and where both sides carry a version, the same version: opus 5 and
1141
+ // opus 5.5 are different models at different prices, but a baseline of the
1142
+ // bare alias 'opus' says nothing about which Opus, so it disagrees with none.
1143
+ function sameModel(a, b) {
1144
+ if (!sameFamily(a, b)) return false;
1145
+ let left = null;
1146
+ let right = null;
1147
+ try {
1148
+ const usage = require('./usage.js');
1149
+ left = usage.parseModelId(a);
1150
+ right = usage.parseModelId(b);
1151
+ } catch (err) {
1152
+ return true;
1153
+ }
1154
+ if (!left || !right || !left.version.length || !right.version.length) return true;
1155
+ return left.version.join('.') === right.version.join('.');
1156
+ }
1157
+
1158
+ function money(value) {
1159
+ return Number.isInteger(value) ? '$' + value : '$' + value.toFixed(2);
1160
+ }
1161
+
1162
+ // A newer model in the SAME family at a lower price is the cheapest saving
1163
+ // there is: no step down in tier, nothing given up, only the price. The advice
1164
+ // channel above only ever says "choose lower"; this says "choose newer", once
1165
+ // per session (brief.js keeps the count), and only from prices on record.
1166
+ //
1167
+ // What the user can do differs by host, and the sentence says only what is
1168
+ // real where it is read. In Claude Code, `/model <id>` switches the session and
1169
+ // saves the model as the default for new sessions (the picker's `s` key is the
1170
+ // this-session-only form), per code.claude.com/docs/en/model-config, read
1171
+ // 2026-09-25. A subagent with no model of its own falls through to the main
1172
+ // conversation's model (docs/en/sub-agents, same day), so a switch reaches
1173
+ // those from their next dispatch. Codex has no /model of that kind, so there
1174
+ // the only lever named is its config.
1175
+ //
1176
+ // The payback figure is exact arithmetic on the two rows, not an estimate of
1177
+ // the session: switching costs one write of the context at the new model's
1178
+ // write price instead of one read at the old read price, and every later turn
1179
+ // saves the difference in read price on that context. The context size cancels
1180
+ // out of the ratio, so the number of turns holds for any session. It counts the
1181
+ // reads alone; the cheaper input and output only shorten it.
1182
+ function newerModelAdvice(tier, options) {
1183
+ const opts = options || {};
1184
+ if (!tier) return null;
1185
+ const usage = opts.usage || require('./usage.js');
1186
+ if (typeof usage.newerSibling !== 'function') return null;
1187
+ const base = tier.baseline || {};
1188
+ const run = tier.running || {};
1189
+ const hostName = opts.host || host.CLAUDE;
1190
+ // A bound the user set outranks the price: nothing said points outside it.
1191
+ const sibling = (model) => {
1192
+ const found = model ? usage.newerSibling(model) : null;
1193
+ return found && allows(opts.bounds, { model: found.to }) ? found : null;
1194
+ };
1195
+ const current = run.model || base.model;
1196
+ const found = sibling(current);
1197
+ // The relay resumes with its own --model when one is set (wake.js), so a
1198
+ // session moved to the newer model still wakes on the old one. Claude Code
1199
+ // only: that is where `relay model` feeds a Claude CLI.
1200
+ const relayFound = hostName === host.CLAUDE && opts.relayModel ? sibling(opts.relayModel) : null;
1201
+ if (!found && !relayFound) return null;
1202
+
1203
+ const name = (family, version) => family.charAt(0).toUpperCase() + family.slice(1) + ' ' + version.join('.');
1204
+ const pct = (was, now) => Math.round((1 - now / was) * 100);
1205
+ const prices = (pair) => {
1206
+ const a = pair.fromRate;
1207
+ const b = pair.toRate;
1208
+ const bits = [];
1209
+ if (b.input < a.input || b.output < a.output) {
1210
+ bits.push(money(b.input) + '/' + money(b.output) + ' per million tokens in and out against ' + money(a.input) + '/' + money(a.output));
1211
+ }
1212
+ if (b.cacheRead < a.cacheRead) {
1213
+ bits.push('cache reads ' + money(b.cacheRead) + ' against ' + money(a.cacheRead) + ', ' + pct(a.cacheRead, b.cacheRead) +
1214
+ '% less on the reads that are most of what a long session spends');
1215
+ }
1216
+ return bits.join('; ');
1217
+ };
1218
+ const family = (pair) => pair.family.charAt(0).toUpperCase() + pair.family.slice(1);
1219
+
1220
+ const sentences = [];
1221
+ if (found) {
1222
+ const a = found.fromRate;
1223
+ const b = found.toRate;
1224
+ sentences.push(name(found.family, found.toVersion) + ' is a newer ' + family(found) + ' at a lower price than the ' +
1225
+ name(found.family, found.fromVersion) + ' running here. At first-party API prices: ' + prices(found) +
1226
+ '. Same tier and a newer release, so no step down.');
1227
+ const pinnedAlready = base.model && String(base.model).toLowerCase().replace(/\[[^\]]*\]\s*$/, '').trim() === found.to;
1228
+ if (hostName === host.CLAUDE) {
1229
+ sentences.push('It is the user\'s switch, not yours: offer `/model ' + found.to + '`, which moves this session and saves it as the ' +
1230
+ 'default for new sessions' + (pinnedAlready ? ' (settings.json already names it, so new sessions start on it either way)' : '') +
1231
+ '. Subagents given no model of their own (and no CLAUDE_CODE_SUBAGENT_MODEL) run on the session\'s model, so they follow from their next dispatch.');
1232
+ // Only where the switch can be made mid-session is its one-off cost
1233
+ // worth a number; elsewhere the offer is a pin for new sessions, which
1234
+ // rebuild anyway.
1235
+ if (b.cacheRead < a.cacheRead) {
1236
+ const turns = (write) => Math.ceil(Math.round(((write * b.input - a.cacheRead) / (a.cacheRead - b.cacheRead)) * 100) / 100);
1237
+ sentences.push('Switching rebuilds the prompt cache once; the cheaper reads alone repay that in about ' + turns(1.25) +
1238
+ ' turns (' + turns(2) + ' on the one-hour cache).');
1239
+ } else {
1240
+ sentences.push('Switching rebuilds the prompt cache once, so on a large context it is worth making at the next session start rather than now.');
1241
+ }
1242
+ } else if (hostName === host.CODEX) {
1243
+ sentences.push('It is the user\'s setting, not yours: offer pinning model = "' + found.to + '" in config.toml for new sessions.');
1244
+ } else {
1245
+ sentences.push('It is the user\'s setting, not yours: offer choosing ' + found.to + ' in this host\'s own model setting.');
1246
+ }
1247
+ }
1248
+ if (relayFound) {
1249
+ const same = found && found.to === relayFound.to && found.from === relayFound.from;
1250
+ sentences.push('Relay wakes are pinned to ' + opts.relayModel + ' on their own' +
1251
+ (same ? '' : ', and ' + name(relayFound.family, relayFound.toVersion) + ' is a newer ' + family(relayFound) +
1252
+ ' at a lower price (' + prices(relayFound) + ')') +
1253
+ ', so a resumed run starts on the older model even after this session switches: offer `/usage-limits:relay model ' + relayFound.to +
1254
+ '`, which is the user\'s setting too.');
1255
+ }
1256
+ const id = (found ? found.from + '->' + found.to : '') + (relayFound ? '|relay:' + relayFound.from + '->' + relayFound.to : '');
1257
+ return {
1258
+ id,
1259
+ from: found ? found.from : null,
1260
+ to: found ? found.to : null,
1261
+ relay: relayFound ? { from: relayFound.from, to: relayFound.to } : null,
1262
+ text: sentences.join(' '),
1263
+ };
1264
+ }
1115
1265
  // One clause for the brief, or two lines for `--baseline`.
1116
1266
  //
1117
1267
  // Where the baseline and the running tier agree there is nothing interesting
@@ -1124,16 +1274,12 @@ function tierLine(tier, options) {
1124
1274
  if (!tier) return null;
1125
1275
  const base = tier.baseline || {};
1126
1276
  const run = tier.running || {};
1127
- const shortModel = (name) => {
1128
- const rank = modelRank(name);
1129
- return rank === null ? name : MODEL_ORDER[rank];
1130
- };
1131
- const runningText = [shortModel(run.model) || shortModel(base.model), run.effort || base.effort].filter(Boolean).join('/');
1277
+ const runningText = [modelLabel(run.model) || modelLabel(base.model), run.effort || base.effort].filter(Boolean).join('/');
1132
1278
  if (!runningText) return null;
1133
- const baseText = [shortModel(base.model), base.effort].filter(Boolean).join('/');
1279
+ const baseText = [modelLabel(base.model), base.effort].filter(Boolean).join('/');
1134
1280
  const differs =
1135
1281
  baseText && runningText !== baseText &&
1136
- (!sameFamily(run.model || base.model, base.model) || (run.effort || base.effort) !== base.effort);
1282
+ (!sameModel(run.model || base.model, base.model) || (run.effort || base.effort) !== base.effort);
1137
1283
  const source = run.source ? ' (' + run.source + ')' : '';
1138
1284
  if (opts.terse) {
1139
1285
  return differs ? runningText + source + ', yours ' + baseText : runningText + source;
@@ -1876,6 +2022,9 @@ module.exports = {
1876
2022
  ultracodeName,
1877
2023
  topTier,
1878
2024
  tierLine,
2025
+ modelLabel,
2026
+ sameModel,
2027
+ newerModelAdvice,
1879
2028
  ledger,
1880
2029
  explain,
1881
2030
  list,
@@ -62,6 +62,10 @@ const RATES = {
62
62
  'claude-mythos-5-1': { input: 10, output: 50, cacheRead: 0.25 },
63
63
  'claude-fable-5': { input: 10, output: 50 },
64
64
  'claude-mythos-5': { input: 10, output: 50 },
65
+ // Opus 5.5 prices reads outright: $0.20 is 0.05x its input, half the tenth
66
+ // rule every other Opus follows (platform.claude.com/docs/en/about-claude/pricing,
67
+ // read 2026-09-25). Left to the multiplier it would be priced at $0.40.
68
+ 'claude-opus-5-5': { input: 4, output: 20, cacheRead: 0.2 },
65
69
  'claude-opus-5': { input: 5, output: 25 },
66
70
  'claude-opus-4-8': { input: 5, output: 25 },
67
71
  'claude-opus-4-7': { input: 5, output: 25 },
@@ -129,6 +133,83 @@ const CACHE_WRITE_5M = 1.25;
129
133
  const CACHE_WRITE_1H = 2;
130
134
  const CACHE_READ = 0.1;
131
135
 
136
+ // The effective cache-read price of a row, in $/MTok.
137
+ function readRateOf(rate) {
138
+ return Number.isFinite(rate.cacheRead) ? rate.cacheRead : rate.input * CACHE_READ;
139
+ }
140
+
141
+ // A model id taken apart into the family word and the version numbers:
142
+ // 'claude-opus-5-5' is opus [5, 5], 'us.anthropic.claude-opus-5-v1:0' is
143
+ // opus [5]. A dated snapshot suffix (20250929) is not part of the version, and a
144
+ // bare alias such as 'opus' has no version at all, so nothing is claimed about
145
+ // which release it resolved to. Mythos stays mythos here: it is priced with
146
+ // Fable, but it is not the same model line.
147
+ function parseModelId(model) {
148
+ const id = normalizeModel(model);
149
+ const found = id.match(/(haiku|sonnet|opus|mythos|fable)((?:-\d+)*)/);
150
+ if (!found) return null;
151
+ const version = [];
152
+ for (const part of found[2].split('-').filter(Boolean)) {
153
+ if (part.length > 2) break;
154
+ version.push(Number(part));
155
+ }
156
+ return { family: found[1], version };
157
+ }
158
+
159
+ function compareVersions(a, b) {
160
+ const length = Math.max(a.length, b.length);
161
+ for (let i = 0; i < length; i += 1) {
162
+ const diff = (a[i] || 0) - (b[i] || 0);
163
+ if (diff !== 0) return diff;
164
+ }
165
+ return 0;
166
+ }
167
+
168
+ // Claude 4.7 and later count text with a newer tokenizer that produces about
169
+ // 30% more tokens for the same text (pricing page, read 2026-09-25). A price per
170
+ // token across that line is not a like-for-like price, so a sibling on the other
171
+ // side of it is never offered as the cheaper one.
172
+ function sameTokenizer(a, b) {
173
+ return (compareVersions(a, [4, 7]) >= 0) === (compareVersions(b, [4, 7]) >= 0);
174
+ }
175
+
176
+ // The newest release of the SAME family that is newer than this one and no
177
+ // dearer on any rate - input, output or cache reads - and cheaper on at least
178
+ // one. That is a saving with no step down in tier. Only a model whose own price
179
+ // is on record qualifies: comparing against a family average would be
180
+ // comparing against a guess.
181
+ function newerSibling(model, table) {
182
+ const rates = table || RATES;
183
+ const me = parseModelId(model);
184
+ if (!me || !me.version.length) return null;
185
+ const from = 'claude-' + me.family + '-' + me.version.join('-');
186
+ const mine = rates[from];
187
+ if (!mine) return null;
188
+ let best = null;
189
+ for (const id of Object.keys(rates)) {
190
+ const other = parseModelId(id);
191
+ if (!other || other.family !== me.family || !other.version.length) continue;
192
+ if (compareVersions(other.version, me.version) <= 0) continue;
193
+ if (!sameTokenizer(other.version, me.version)) continue;
194
+ const theirs = rates[id];
195
+ const noDearer = theirs.input <= mine.input && theirs.output <= mine.output && readRateOf(theirs) <= readRateOf(mine);
196
+ const cheaper = theirs.input < mine.input || theirs.output < mine.output || readRateOf(theirs) < readRateOf(mine);
197
+ if (!noDearer || !cheaper) continue;
198
+ if (!best || compareVersions(other.version, best.version) > 0) best = { id, version: other.version, rate: theirs };
199
+ }
200
+ if (!best) return null;
201
+ const shape = (rate) => ({ input: rate.input, output: rate.output, cacheRead: readRateOf(rate) });
202
+ return {
203
+ family: me.family,
204
+ from,
205
+ fromVersion: me.version,
206
+ fromRate: shape(mine),
207
+ to: best.id,
208
+ toVersion: best.version,
209
+ toRate: shape(best.rate),
210
+ };
211
+ }
212
+
132
213
  // `family` marks a window that caps one model family rather than the account
133
214
  // as a whole. It is what tells the rest of the file that a window cannot stop
134
215
  // work which does not use that family.
@@ -4282,6 +4363,8 @@ module.exports = {
4282
4363
  WINDOWS,
4283
4364
  rateFor,
4284
4365
  familyOf,
4366
+ parseModelId,
4367
+ newerSibling,
4285
4368
  familyAverage,
4286
4369
  familiesInUse,
4287
4370
  appliesTo,