diff --git a/.changelog/next/changed-issue-4163.md b/.changelog/next/changed-issue-4163.md new file mode 100644 index 0000000000..ed9e66036f --- /dev/null +++ b/.changelog/next/changed-issue-4163.md @@ -0,0 +1 @@ +- Deduped the identical Claude Opus rate rows in the usage-cost table: the tier now shares one rate pair and the opus family rule derives its pointer, so an opus model bump no longer needs a second hand-edit (#4163) diff --git a/server/lib/modelPricing.js b/server/lib/modelPricing.js index aeacbc1f12..ff766f24f8 100644 --- a/server/lib/modelPricing.js +++ b/server/lib/modelPricing.js @@ -31,16 +31,32 @@ export const PRICING_AS_OF = '2026-07-12'; +/** + * Every shipped Claude Opus generation bills at the same published rate, so the + * tier owns one [input, output] pair rather than a hand-copied literal per row. + * `OPUS_MODEL_IDS` is ordered NEWEST FIRST: its head is the id the `/opus/i` + * family rule reports for opus ids the table doesn't list, so an opus bump is a + * single prepend here — the family rule no longer has to be re-pointed by hand + * (it was, at every bump through migration 206, for zero behavioral effect). + */ +const OPUS_TIER_RATES = [5.0, 25.0]; +const OPUS_MODEL_IDS = [ + 'claude-opus-5', + 'claude-opus-4-8', + 'claude-opus-4-7', + 'claude-opus-4-6', + 'claude-opus-4-5', +]; + /** USD per 1M tokens: [input, output]. Exact model-id matches. */ const EXACT_RATES = { // Anthropic 'claude-fable-5': [10.0, 50.0], 'claude-mythos-5': [10.0, 50.0], - 'claude-opus-5': [5.0, 25.0], - 'claude-opus-4-8': [5.0, 25.0], - 'claude-opus-4-7': [5.0, 25.0], - 'claude-opus-4-6': [5.0, 25.0], - 'claude-opus-4-5': [5.0, 25.0], + // Cloned per row so each key owns its pair exactly as the hand-written rows + // above and below do — sharing one array across five keys would make any + // future edit to one opus row silently rewrite the whole tier. + ...Object.fromEntries(OPUS_MODEL_IDS.map((id) => [id, [...OPUS_TIER_RATES]])), 'claude-sonnet-5': [2.0, 10.0], // intro pricing through 2026-08-31 ($3/$15 after) 'claude-sonnet-4-6': [3.0, 15.0], 'claude-sonnet-4-5': [3.0, 15.0], @@ -94,7 +110,10 @@ const EXACT_KEYS_BY_LENGTH = Object.keys(EXACT_RATES).sort((a, b) => b.length - */ const FAMILY_RULES = [ { test: /fable|mythos/i, rateModel: 'claude-fable-5' }, - { test: /opus/i, rateModel: 'claude-opus-5' }, + // Reports the newest listed opus id (see OPUS_MODEL_IDS) — the whole tier + // shares one rate pair, so the pointer only supplies the label, and deriving + // it means an opus bump never has to edit this line. + { test: /opus/i, rateModel: OPUS_MODEL_IDS[0] }, { test: /sonnet[-.]?5/i, rateModel: 'claude-sonnet-5' }, { test: /sonnet/i, rateModel: 'claude-sonnet-4-5' }, { test: /haiku/i, rateModel: 'claude-haiku-4-5' }, diff --git a/server/lib/modelPricing.test.js b/server/lib/modelPricing.test.js index f75fd5e833..9449aeb8a1 100644 --- a/server/lib/modelPricing.test.js +++ b/server/lib/modelPricing.test.js @@ -10,11 +10,68 @@ describe('resolveModelRates', () => { }); it('resolves CLI shorthand model names via family rules', () => { - expect(resolveModelRates('claude-code', 'opus')).toMatchObject({ rateModel: 'claude-opus-5', matched: 'family' }); + // Asserted by RATES, not by pointer id: the whole opus tier shares one rate + // pair, so which listed opus id gets reported is a bump-to-bump detail the + // suite should not have to be edited for. The pointer's validity is pinned + // by the dedicated test below. + expect(resolveModelRates('claude-code', 'opus')).toMatchObject({ inputPer1M: 5, outputPer1M: 25, matched: 'family' }); expect(resolveModelRates('claude-code', 'sonnet')).toMatchObject({ rateModel: 'claude-sonnet-4-5', matched: 'family' }); expect(resolveModelRates('claude-code', 'haiku')).toMatchObject({ rateModel: 'claude-haiku-4-5', matched: 'family' }); }); + // #4163: the opus tier is one shared rate pair, and the `/opus/i` family rule + // derives its pointer from the model list instead of naming an id by hand. + // These three pin the properties that made the hand-maintained pointer safe, + // so a future opus bump is a one-line prepend with no other edit anywhere. + describe('opus tier', () => { + const LISTED_OPUS_IDS = [ + 'claude-opus-5', 'claude-opus-4-8', 'claude-opus-4-7', 'claude-opus-4-6', 'claude-opus-4-5', + ]; + + it('bills every listed opus generation at the same tier rates', () => { + for (const id of LISTED_OPUS_IDS) { + expect(resolveModelRates('claude-code', id)).toMatchObject({ + rateModel: id, inputPer1M: 5, outputPer1M: 25, matched: 'exact', + }); + } + }); + + // The point of the dedup: an opus id the table has never heard of still + // prices at $5/$25 without anyone re-pointing a family rule at it. + it('prices an unlisted opus id at tier rates without a pointer bump', () => { + for (const id of [ + 'opus', 'claude-opus-9', 'claude-opus-6-2', 'global.anthropic.claude-opus-7-20270101-v1:0', + ]) { + expect(resolveModelRates('claude-code', id)).toMatchObject({ + inputPer1M: 5, outputPer1M: 25, matched: 'family', + }); + } + }); + + // The family rule reports a LABEL, and downstream code leans on it: the UI + // prints "Priced as ", and usageReconciler compares two ids' + // rateModel to decide whether they are the same family. So the pointer must + // stay a non-null, exactly-resolvable key whose rates agree with the tier — + // a null or synthetic label would silently break both. + it('reports a family label that is itself an exact table id at the same rates', () => { + const viaFamily = resolveModelRates('claude-code', 'opus'); + // Shape-matched rather than hardcoded to the current head id, so the next + // opus bump doesn't have to edit this suite — that per-bump edit is what + // the refactor removed. The exact-resolution check below is what actually + // pins the pointer to a real table key. + expect(viaFamily.rateModel).toMatch(/^claude-opus-\d/); + expect(resolveModelRates('claude-code', viaFamily.rateModel)).toMatchObject({ + rateModel: viaFamily.rateModel, + inputPer1M: viaFamily.inputPer1M, + outputPer1M: viaFamily.outputPer1M, + matched: 'exact', + }); + // …and every opus id agrees on that label, which is what lets the + // reconciler treat `opus` and `claude-opus-N` as one model. + expect(resolveModelRates('claude-code', 'claude-opus-9').rateModel).toBe(viaFamily.rateModel); + }); + }); + it('resolves fable/mythos to the Claude 5 flagship rates', () => { expect(resolveModelRates('claude-code', 'claude-fable-5')).toMatchObject({ inputPer1M: 10, outputPer1M: 50 }); expect(resolveModelRates('claude-code', 'fable')).toMatchObject({ rateModel: 'claude-fable-5', matched: 'family' });