diff --git a/agent/usage_pricing.py b/agent/usage_pricing.py index cd61af18e9..3bc44b1abd 100644 --- a/agent/usage_pricing.py +++ b/agent/usage_pricing.py @@ -913,15 +913,25 @@ def _normalize_bedrock_model_name(model: str) -> str: """Normalize a Bedrock model id to its bare foundation-model form. Bedrock cross-region inference profiles prefix the foundation model id - with a region scope (``us.`` / ``global.`` / ``eu.`` / ``ap.`` / ``jp.``), + with a region scope (``us.`` / ``global.`` / ``eu.`` / ``apac.`` / ...), e.g. ``us.anthropic.claude-opus-4-7``. The pricing table is keyed on the bare ``anthropic.claude-*`` id, so the prefix must be stripped before the - lookup or every cross-region session prices as unknown. Mirrors the - prefix list in ``bedrock_adapter.is_anthropic_bedrock_model``. Also - normalizes dot-notation version numbers (``4.7`` → ``4-7``). + lookup or every cross-region session prices as unknown. Also normalizes + dot-notation version numbers (``4.7`` → ``4-7``). """ name = model.lower().strip() - for prefix in ("us.", "global.", "eu.", "ap.", "jp."): + for prefix in ( + "global.", + "us.", + "eu.", + "ap.", + "apac.", + "jp.", + "ca.", + "sa.", + "me.", + "af.", + ): if name.startswith(prefix): name = name[len(prefix):] break @@ -967,6 +977,20 @@ def _lookup_official_docs_pricing(route: BillingRoute) -> Optional[PricingEntry] entry = _OFFICIAL_DOCS_PRICING.get((route.provider, normalized)) if entry: return entry + bedrock_entries = ( + (known_model, known_entry) + for (provider, known_model), known_entry in _OFFICIAL_DOCS_PRICING.items() + if provider == route.provider + ) + for known_model, known_entry in sorted( + bedrock_entries, + key=lambda item: len(item[0]), + reverse=True, + ): + if normalized == known_model or normalized.startswith( + (f"{known_model}-", f"{known_model}:") + ): + return known_entry return None diff --git a/tests/agent/test_usage_pricing.py b/tests/agent/test_usage_pricing.py index 04aa4ec4c9..f9c0e318e0 100644 --- a/tests/agent/test_usage_pricing.py +++ b/tests/agent/test_usage_pricing.py @@ -369,6 +369,43 @@ def test_bedrock_cross_region_profile_prefix_resolves_to_pricing(): assert scoped.cache_read_cost_per_million == bare.cache_read_cost_per_million +def test_bedrock_versioned_inference_profile_resolves_to_bare_pricing(): + """Bedrock profile IDs may include the provider's dated version suffix. + + The pricing table intentionally uses shorter model-family IDs, so the + lookup needs a longest-prefix fallback after stripping the region scope. + """ + bare = get_pricing_entry("anthropic.claude-sonnet-4-6", provider="bedrock") + assert bare is not None + + for model in ( + "us.anthropic.claude-sonnet-4-6-20250514-v1:0", + "global.anthropic.claude-sonnet-4-6-20250514-v1:0", + ): + scoped = get_pricing_entry(model, provider="bedrock") + assert scoped is not None, model + assert scoped.input_cost_per_million == bare.input_cost_per_million + assert scoped.output_cost_per_million == bare.output_cost_per_million + assert scoped.cache_read_cost_per_million == bare.cache_read_cost_per_million + assert scoped.cache_write_cost_per_million == bare.cache_write_cost_per_million + + +def test_bedrock_pricing_supports_less_common_inference_profile_prefixes(): + """AWS also exposes profile scopes beyond us./global./eu.; those should + not silently fall through to unknown pricing. + """ + entry = get_pricing_entry( + "apac.anthropic.claude-haiku-4-5-20251001-v1:0", + provider="bedrock", + ) + + assert entry is not None + assert float(entry.input_cost_per_million) == 0.8 + assert float(entry.output_cost_per_million) == 4.0 + assert float(entry.cache_read_cost_per_million) == 0.08 + assert float(entry.cache_write_cost_per_million) == 1.0 + + def test_bedrock_claude_cached_session_estimates_cost_not_unknown(): """A Bedrock Claude session with cache hits must produce a dollar estimate, not ``unknown`` — the user-visible symptom in #50295.