Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changelog/next/changed-issue-4163.md
Original file line number Diff line number Diff line change
@@ -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)
31 changes: 25 additions & 6 deletions server/lib/modelPricing.js
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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' },
Expand Down
59 changes: 58 additions & 1 deletion server/lib/modelPricing.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <rateModel>", 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' });
Expand Down