diff --git a/agent/usage_pricing.py b/agent/usage_pricing.py index aa306fa12f8c..54136847be5f 100644 --- a/agent/usage_pricing.py +++ b/agent/usage_pricing.py @@ -681,19 +681,36 @@ 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``) and the documented + trailing date, revision, and profile components (``-20250514-v1:0``). """ 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 name = re.sub(r"(\d+)\.(\d+)", r"\1-\2", name) + # Bedrock inference profile IDs append these documented components to the + # foundation model ID. Strip only the trailing forms, not arbitrary model + # name continuations that could be a distinct SKU. + name = re.sub(r":\d+$", "", name) + name = re.sub(r"-v\d+$", "", name) + name = re.sub(r"-\d{8}$", "", name) return name diff --git a/tests/agent/test_usage_pricing.py b/tests/agent/test_usage_pricing.py index 3bd68ae2344d..580bfc749900 100644 --- a/tests/agent/test_usage_pricing.py +++ b/tests/agent/test_usage_pricing.py @@ -299,6 +299,61 @@ 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. + """ + bare = get_pricing_entry("anthropic.claude-haiku-4-5", provider="bedrock") + entry = get_pricing_entry( + "apac.anthropic.claude-haiku-4-5-20251001-v1:0", + provider="bedrock", + ) + + assert bare is not None + assert entry is not None + for field in ( + "input_cost_per_million", + "output_cost_per_million", + "cache_read_cost_per_million", + "cache_write_cost_per_million", + ): + assert getattr(entry, field) == getattr(bare, field) + + +def test_bedrock_unknown_model_continuation_does_not_use_base_pricing(): + """Unrecognized Bedrock SKUs must remain unknown rather than inheriting a + similarly named model family's price. + """ + assert ( + get_pricing_entry( + "anthropic.claude-sonnet-4-6-experimental", + provider="bedrock", + ) + is None + ) + + 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.