Skip to content

fix(pricing): map opus-4-7 ids to claude-opus-4-7 instead of legacy claude-opus-4 - #580

Merged
junhoyeo merged 1 commit into
junhoyeo:mainfrom
xczllgit:fix/opus-pricing-normalization
May 24, 2026
Merged

fix(pricing): map opus-4-7 ids to claude-opus-4-7 instead of legacy claude-opus-4#580
junhoyeo merged 1 commit into
junhoyeo:mainfrom
xczllgit:fix/opus-pricing-normalization

Conversation

@xczllgit

@xczllgit xczllgit commented May 21, 2026

Copy link
Copy Markdown
Contributor

Summary

  • normalize_model_name only had explicit branches for opus 4.5 and 4.6; anything else with "opus" + "4" fell through to a catch-all that returned claude-opus-4.
  • For ids like aws.claude-opus-4-7 this caused exact_match_openrouter to reach anthropic/claude-opus-4 via the model_part index β€” the legacy opus 4 entry priced at $15/$75 per M instead of opus 4.7's $5/$25.
  • Add an explicit 4.7 branch in front of 4.6 so opus-4-7 ids resolve to claude-opus-4-7. Catch-all is left in place to preserve the existing behavior tests rely on (e.g. test_normalize_opus_4_60_does_not_map_to_4_6).

Why this matters

A real session with 8.4M input / 873K output / 41.3M cache-read / 12.1M cache-write tokens on aws.claude-opus-4-7 was being billed at ~$480 instead of the correct ~$160 β€” a roughly 3x overcharge β€” because the resolver picked the legacy opus 4 price table.

How I traced it

The lookup chain for aws.claude-opus-4-7:

  1. aws. is not in PROVIDER_PREFIXES, so strip_known_provider_prefix skips it.
  2. No exact LiteLLM/OpenRouter match.
  3. normalize_version_separator produces aws.claude-opus-4.7 β€” still no match.
  4. normalize_model_name falls through to claude-opus-4 (the bug).
  5. exact_match_openrouter("claude-opus-4") reaches anthropic/claude-opus-4 via openrouter_model_part, returning the legacy $15/$75 entry.

After the fix, step 4 short-circuits to claude-opus-4-7, which exists in upstream LiteLLM data and prices correctly.

Relationship to #578

Independent. #578 normalizes anthropic/claude-{major}-{minor}-{family} (family-suffix order, e.g. anthropic/claude-4-6-sonnet) and adds 4.5/4.6 aliases. It does not touch normalize_model_name, has no 4.7 entries, and aws.-prefixed ids never reach its new code path. The two fixes don't overlap and won't conflict at merge.

Test plan

  • cargo test -p tokscale-core --lib pricing::lookup β€” 129 passed, 0 failed (3 new tests included)
  • cargo test -p tokscale-core β€” 674 + 10 + 3 + 0 passed across all suites, 0 failed
  • New regression tests:
    • test_normalize_opus_4_7_prefers_4_7_over_4 β€” short-form opus-4-7
    • test_normalize_opus_4_7_dot_prefers_4_7_over_4 β€” dot-form opus-4.7
    • test_aws_opus_4_7_does_not_degrade_to_opus_4 β€” the actual reported case, asserts both the resolved key (claude-opus-4-7) and that computed cost lands in the 4.7 band ($160) rather than legacy opus-4 band ($480)

πŸ€– Generated with Claude Code


Summary by cubic

Fix pricing normalization for Opus 4.7 so IDs resolve to claude-opus-4-7 instead of the legacy claude-opus-4. This corrects cost calculations for aws.claude-opus-4-7 and prevents ~3x overcharges.

  • Bug Fixes
    • Add explicit 4.7 branch in normalize_model_name (before 4.6) to route opus-4-7 and opus-4.7 to claude-opus-4-7.
    • Stop aws.claude-opus-4-7 from falling back to anthropic/claude-opus-4; add regression tests to verify the resolved key and 4.7 pricing.

Written for commit 6f05b84. Summary will update on new commits. Review in cubic

…de-opus-4

`normalize_model_name` only had explicit branches for opus 4.5 and 4.6
and fell through to a catch-all `else if contains_delimited_fragment("4")`
that returned `claude-opus-4`. For ids like `aws.claude-opus-4-7` the
catch-all swallowed 4.7, after which `exact_match_openrouter` reached
`anthropic/claude-opus-4` via the `model_part` index β€” i.e. the legacy
opus 4 entry priced at $15/$75 per M instead of the 4.7 entry at $5/$25.
Real-world impact: a session with 8.4M input / 873K output / 41.3M
cache-read / 12.1M cache-write tokens billed at ~$480 instead of ~$160,
roughly a 3x overcharge.

Add an explicit 4.7 branch in front of 4.6 so the opus normalizer
short-circuits to `claude-opus-4-7` before reaching the catch-all.
Future minor versions (4.8+) will still need their own branches; the
catch-all is left in place to preserve the existing behavior tests
(`test_normalize_opus_4_60_does_not_map_to_4_6` etc.) rely on.

Add three regression tests:
- `test_normalize_opus_4_7_prefers_4_7_over_4` β€” short-form `opus-4-7`
- `test_normalize_opus_4_7_dot_prefers_4_7_over_4` β€” dot-form `opus-4.7`
- `test_aws_opus_4_7_does_not_degrade_to_opus_4` β€” the actual reported
  case, asserting the resolved key and the computed cost falls in the
  4.7 price band (~$160) rather than the legacy opus-4 band (~$480).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@vercel

vercel Bot commented May 21, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
tokscale Ignored Ignored Preview May 21, 2026 7:41am

Request Review

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 1 file

Re-trigger cubic

@junhoyeo

Copy link
Copy Markdown
Owner

Thanks!

@junhoyeo
junhoyeo merged commit 4bfa586 into junhoyeo:main May 24, 2026
14 checks passed
@junhoyeo

Copy link
Copy Markdown
Owner

@xczllgit this has been merged to v3.0.0: https://github.com/junhoyeo/tokscale/releases/tag/v3.0.0 thanks for the contribution!

junhoyeo added a commit that referenced this pull request Jun 10, 2026
…ng (#634)

* fix(pricing): parse modern Claude version from id instead of hardcoding

`normalize_model_name` matched each opus/sonnet/haiku minor version with a
hardcoded branch and a catch-all `claude-opus-4`. Every new release (4.5 β†’
4.6 β†’ 4.7 β†’ 4.8) needed a new branch; a missing one let ids like
`aws.claude-opus-4-8` fall through to the catch-all, which OpenRouter then
resolved to legacy `anthropic/claude-opus-4` ($15/$75 per M instead of
$5/$25) β€” a ~3x overcharge. This repeated for 4.7 (#580) and again for 4.8.

The modern Claude line (major >= 4) follows the regular
`claude-{family}-{major}[-{minor}]` scheme, so parse the version straight
from the id and build the canonical key dynamically. New minor releases
(4.9, 5.0, …) now resolve to their own pricing key with no code change β€”
pricing values still come entirely from the upstream datasets.

Boundary contract from the old matcher is preserved: `opus-4-60` β†’
`claude-opus-4` (two-digit minor degrades to major), `opus-14-6` β†’ None
(two-digit major is not the modern line), undelimited `opus4`/`opus-4x` β†’
None. The irregular legacy 3.x naming keeps its explicit branches.

Tests:
- test_normalize_future_minor_versions_resolve_without_hardcoding β€” 4.9/5.0
  and cross-family (sonnet/haiku) parse without a hardcoded entry
- test_normalize_modern_claude_boundaries β€” locks the boundary contract
- 4.8 regression tests mirroring the 4.7 set (short/dot/aws forms + cost band)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* style: wrap haiku condition to satisfy cargo fmt

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(pricing): enforce never-degrade across all Claude families and majors

Generalize the opus-only version handling in PricingLookup so every
Claude family (opus, sonnet, haiku, fable) gets the same guarantees:

- normalize_claude_opus_4_minor -> normalize_claude_family_minor:
  family/major/minor parsed from the id in both orders (sonnet-4.7,
  claude-4-6-sonnet), so reversed-order sonnet/haiku ids resolve to
  their canonical key instead of cross-family fuzzy fallbacks.
- normalize_claude_family_bare_major: bare modern majors (claude-opus-5,
  claude-sonnet-5, fable-5) normalize to a canonical key that resolves
  only via an exact dataset hit.
- contains_delimited_modern_major_minor: the 4.x major-minor None-guard
  in normalize_model_name now covers majors 4-9, so 4-60 / 5-0 / dated
  forms stay unresolved instead of degrading.
- resolves_unsafe_claude_version (was resolves_different_claude_opus_4_
  minor): vetoes cross-family resolutions (bedrock sonnet ids billed as
  opus), cross-version resolutions for any family (sonnet-4-7 ->
  sonnet-4.6, haiku-4-6 -> haiku-4.5 / 3.5-haiku), and any modern-Claude
  resolution for ids whose version could not be parsed. Bare major 4
  stays unpinned to preserve existing claude-opus-4 fuzzy behavior.

Whether a version is "known" remains dataset-driven, exactly as it was
for opus: the canonical key either exact-matches the pricing dataset or
the id resolves unpriced. No hardcoded minor lists were added.

Reworks PR #634 on top of main's mechanisms instead of merging its
parallel parser.

Constraint: unknown minors/majors must resolve unpriced, never to a cheaper or different price
Constraint: all of main's existing pricing tests must pass unchanged
Rejected: PR #634's original generic parser | degrades two-digit minors (opus-4-60 -> claude-opus-4) and drops reversed-order coverage
Rejected: extending the hardcoded 4.5/4.6/4.7 allowlist in has_unrecognized_claude_four_minor | recreates the new-minor-release maintenance trap PR #634 set out to remove
Rejected: pinning bare major 4 ids (claude-opus-4) to exact-only | changes long-standing dated/regional 4.x resolution covered by existing tests
Confidence: high
Scope-risk: moderate
Directive: requested_claude_version deliberately unpins bare major 4; do not "complete" it to all majors without auditing dated-id (claude-opus-4-20250514) and regional-key resolution
Not-tested: provider-hinted lookups (lookup_with_provider) against cross-family adversarial datasets; models.dev reseller keys that legitimately embed reversed-order ids (cortecs/claude-4-6-sonnet) are covered only via real-data probing

---------

Co-authored-by: xiaochaozheng <xiaochaozheng@meituan.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Junho Yeo <i@junho.io>
@xczllgit
xczllgit deleted the fix/opus-pricing-normalization branch June 11, 2026 01:50
pinion05 added a commit to pinion05/tokscale that referenced this pull request Jun 23, 2026
…ng (junhoyeo#634)

* fix(pricing): parse modern Claude version from id instead of hardcoding

`normalize_model_name` matched each opus/sonnet/haiku minor version with a
hardcoded branch and a catch-all `claude-opus-4`. Every new release (4.5 β†’
4.6 β†’ 4.7 β†’ 4.8) needed a new branch; a missing one let ids like
`aws.claude-opus-4-8` fall through to the catch-all, which OpenRouter then
resolved to legacy `anthropic/claude-opus-4` ($15/$75 per M instead of
$5/$25) β€” a ~3x overcharge. This repeated for 4.7 (junhoyeo#580) and again for 4.8.

The modern Claude line (major >= 4) follows the regular
`claude-{family}-{major}[-{minor}]` scheme, so parse the version straight
from the id and build the canonical key dynamically. New minor releases
(4.9, 5.0, …) now resolve to their own pricing key with no code change β€”
pricing values still come entirely from the upstream datasets.

Boundary contract from the old matcher is preserved: `opus-4-60` β†’
`claude-opus-4` (two-digit minor degrades to major), `opus-14-6` β†’ None
(two-digit major is not the modern line), undelimited `opus4`/`opus-4x` β†’
None. The irregular legacy 3.x naming keeps its explicit branches.

Tests:
- test_normalize_future_minor_versions_resolve_without_hardcoding β€” 4.9/5.0
  and cross-family (sonnet/haiku) parse without a hardcoded entry
- test_normalize_modern_claude_boundaries β€” locks the boundary contract
- 4.8 regression tests mirroring the 4.7 set (short/dot/aws forms + cost band)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* style: wrap haiku condition to satisfy cargo fmt

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(pricing): enforce never-degrade across all Claude families and majors

Generalize the opus-only version handling in PricingLookup so every
Claude family (opus, sonnet, haiku, fable) gets the same guarantees:

- normalize_claude_opus_4_minor -> normalize_claude_family_minor:
  family/major/minor parsed from the id in both orders (sonnet-4.7,
  claude-4-6-sonnet), so reversed-order sonnet/haiku ids resolve to
  their canonical key instead of cross-family fuzzy fallbacks.
- normalize_claude_family_bare_major: bare modern majors (claude-opus-5,
  claude-sonnet-5, fable-5) normalize to a canonical key that resolves
  only via an exact dataset hit.
- contains_delimited_modern_major_minor: the 4.x major-minor None-guard
  in normalize_model_name now covers majors 4-9, so 4-60 / 5-0 / dated
  forms stay unresolved instead of degrading.
- resolves_unsafe_claude_version (was resolves_different_claude_opus_4_
  minor): vetoes cross-family resolutions (bedrock sonnet ids billed as
  opus), cross-version resolutions for any family (sonnet-4-7 ->
  sonnet-4.6, haiku-4-6 -> haiku-4.5 / 3.5-haiku), and any modern-Claude
  resolution for ids whose version could not be parsed. Bare major 4
  stays unpinned to preserve existing claude-opus-4 fuzzy behavior.

Whether a version is "known" remains dataset-driven, exactly as it was
for opus: the canonical key either exact-matches the pricing dataset or
the id resolves unpriced. No hardcoded minor lists were added.

Reworks PR junhoyeo#634 on top of main's mechanisms instead of merging its
parallel parser.

Constraint: unknown minors/majors must resolve unpriced, never to a cheaper or different price
Constraint: all of main's existing pricing tests must pass unchanged
Rejected: PR junhoyeo#634's original generic parser | degrades two-digit minors (opus-4-60 -> claude-opus-4) and drops reversed-order coverage
Rejected: extending the hardcoded 4.5/4.6/4.7 allowlist in has_unrecognized_claude_four_minor | recreates the new-minor-release maintenance trap PR junhoyeo#634 set out to remove
Rejected: pinning bare major 4 ids (claude-opus-4) to exact-only | changes long-standing dated/regional 4.x resolution covered by existing tests
Confidence: high
Scope-risk: moderate
Directive: requested_claude_version deliberately unpins bare major 4; do not "complete" it to all majors without auditing dated-id (claude-opus-4-20250514) and regional-key resolution
Not-tested: provider-hinted lookups (lookup_with_provider) against cross-family adversarial datasets; models.dev reseller keys that legitimately embed reversed-order ids (cortecs/claude-4-6-sonnet) are covered only via real-data probing

---------

Co-authored-by: xiaochaozheng <xiaochaozheng@meituan.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Junho Yeo <i@junho.io>
t1000040 pushed a commit to tmobi-internal/tokscale that referenced this pull request Jun 30, 2026
…ng (junhoyeo#634)

* fix(pricing): parse modern Claude version from id instead of hardcoding

`normalize_model_name` matched each opus/sonnet/haiku minor version with a
hardcoded branch and a catch-all `claude-opus-4`. Every new release (4.5 β†’
4.6 β†’ 4.7 β†’ 4.8) needed a new branch; a missing one let ids like
`aws.claude-opus-4-8` fall through to the catch-all, which OpenRouter then
resolved to legacy `anthropic/claude-opus-4` ($15/$75 per M instead of
$5/$25) β€” a ~3x overcharge. This repeated for 4.7 (junhoyeo#580) and again for 4.8.

The modern Claude line (major >= 4) follows the regular
`claude-{family}-{major}[-{minor}]` scheme, so parse the version straight
from the id and build the canonical key dynamically. New minor releases
(4.9, 5.0, …) now resolve to their own pricing key with no code change β€”
pricing values still come entirely from the upstream datasets.

Boundary contract from the old matcher is preserved: `opus-4-60` β†’
`claude-opus-4` (two-digit minor degrades to major), `opus-14-6` β†’ None
(two-digit major is not the modern line), undelimited `opus4`/`opus-4x` β†’
None. The irregular legacy 3.x naming keeps its explicit branches.

Tests:
- test_normalize_future_minor_versions_resolve_without_hardcoding β€” 4.9/5.0
  and cross-family (sonnet/haiku) parse without a hardcoded entry
- test_normalize_modern_claude_boundaries β€” locks the boundary contract
- 4.8 regression tests mirroring the 4.7 set (short/dot/aws forms + cost band)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* style: wrap haiku condition to satisfy cargo fmt

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(pricing): enforce never-degrade across all Claude families and majors

Generalize the opus-only version handling in PricingLookup so every
Claude family (opus, sonnet, haiku, fable) gets the same guarantees:

- normalize_claude_opus_4_minor -> normalize_claude_family_minor:
  family/major/minor parsed from the id in both orders (sonnet-4.7,
  claude-4-6-sonnet), so reversed-order sonnet/haiku ids resolve to
  their canonical key instead of cross-family fuzzy fallbacks.
- normalize_claude_family_bare_major: bare modern majors (claude-opus-5,
  claude-sonnet-5, fable-5) normalize to a canonical key that resolves
  only via an exact dataset hit.
- contains_delimited_modern_major_minor: the 4.x major-minor None-guard
  in normalize_model_name now covers majors 4-9, so 4-60 / 5-0 / dated
  forms stay unresolved instead of degrading.
- resolves_unsafe_claude_version (was resolves_different_claude_opus_4_
  minor): vetoes cross-family resolutions (bedrock sonnet ids billed as
  opus), cross-version resolutions for any family (sonnet-4-7 ->
  sonnet-4.6, haiku-4-6 -> haiku-4.5 / 3.5-haiku), and any modern-Claude
  resolution for ids whose version could not be parsed. Bare major 4
  stays unpinned to preserve existing claude-opus-4 fuzzy behavior.

Whether a version is "known" remains dataset-driven, exactly as it was
for opus: the canonical key either exact-matches the pricing dataset or
the id resolves unpriced. No hardcoded minor lists were added.

Reworks PR junhoyeo#634 on top of main's mechanisms instead of merging its
parallel parser.

Constraint: unknown minors/majors must resolve unpriced, never to a cheaper or different price
Constraint: all of main's existing pricing tests must pass unchanged
Rejected: PR junhoyeo#634's original generic parser | degrades two-digit minors (opus-4-60 -> claude-opus-4) and drops reversed-order coverage
Rejected: extending the hardcoded 4.5/4.6/4.7 allowlist in has_unrecognized_claude_four_minor | recreates the new-minor-release maintenance trap PR junhoyeo#634 set out to remove
Rejected: pinning bare major 4 ids (claude-opus-4) to exact-only | changes long-standing dated/regional 4.x resolution covered by existing tests
Confidence: high
Scope-risk: moderate
Directive: requested_claude_version deliberately unpins bare major 4; do not "complete" it to all majors without auditing dated-id (claude-opus-4-20250514) and regional-key resolution
Not-tested: provider-hinted lookups (lookup_with_provider) against cross-family adversarial datasets; models.dev reseller keys that legitimately embed reversed-order ids (cortecs/claude-4-6-sonnet) are covered only via real-data probing

---------

Co-authored-by: xiaochaozheng <xiaochaozheng@meituan.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Junho Yeo <i@junho.io>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants