diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 6c22b9912..01fb8e48b 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -986,6 +986,26 @@ def read_hermes_oauth_credentials() -> Optional[Dict[str, Any]]: # --------------------------------------------------------------------------- +def _is_bedrock_model_id(model: str) -> bool: + """Detect AWS Bedrock model IDs that use dots as namespace separators. + + Bedrock model IDs come in two forms: + - Bare: ``anthropic.claude-opus-4-7`` + - Regional (inference profiles): ``us.anthropic.claude-sonnet-4-5-v1:0`` + + In both cases the dots separate namespace components, not version + numbers, and must be preserved verbatim for the Bedrock API. + """ + lower = model.lower() + # Regional inference-profile prefixes + if any(lower.startswith(p) for p in ("global.", "us.", "eu.", "ap.", "jp.")): + return True + # Bare Bedrock model IDs: provider.model-family + if lower.startswith("anthropic."): + return True + return False + + def normalize_model_name(model: str, preserve_dots: bool = False) -> str: """Normalize a model name for the Anthropic API. @@ -993,11 +1013,19 @@ def normalize_model_name(model: str, preserve_dots: bool = False) -> str: - Converts dots to hyphens in version numbers (OpenRouter uses dots, Anthropic uses hyphens: claude-opus-4.6 → claude-opus-4-6), unless preserve_dots is True (e.g. for Alibaba/DashScope: qwen3.5-plus). + - Preserves Bedrock model IDs (``anthropic.claude-opus-4-7``) and + regional inference profiles (``us.anthropic.claude-*``) whose dots + are namespace separators, not version separators. """ lower = model.lower() if lower.startswith("anthropic/"): model = model[len("anthropic/"):] if not preserve_dots: + # Bedrock model IDs use dots as namespace separators + # (e.g. "anthropic.claude-opus-4-7", "us.anthropic.claude-*"). + # These must not be converted to hyphens. See issue #12295. + if _is_bedrock_model_id(model): + return model # OpenRouter uses dots for version separators (claude-opus-4.6), # Anthropic uses hyphens (claude-opus-4-6). Convert dots to hyphens. model = model.replace(".", "-") diff --git a/tests/agent/test_bedrock_integration.py b/tests/agent/test_bedrock_integration.py index 202bd3ebd..094d25207 100644 --- a/tests/agent/test_bedrock_integration.py +++ b/tests/agent/test_bedrock_integration.py @@ -376,17 +376,15 @@ class TestBedrockModelNameNormalization: "apac.anthropic.claude-haiku-4-5", preserve_dots=True ) == "apac.anthropic.claude-haiku-4-5" - def test_preserve_false_mangles_as_documented(self): - """Canary: with ``preserve_dots=False`` the function still - produces the broken all-hyphen form — this is the shape that - Bedrock rejected and that the fix avoids. Keeping this test - locks in the existing behaviour of ``normalize_model_name`` so a - future refactor doesn't accidentally decouple the knob from its - effect.""" + def test_bedrock_prefix_preserved_without_preserve_dots(self): + """Bedrock inference profile IDs are auto-detected by prefix and + always returned unmangled -- ``preserve_dots`` is irrelevant for + these IDs because the dots are namespace separators, not version + separators. Regression for #12295.""" from agent.anthropic_adapter import normalize_model_name assert normalize_model_name( "global.anthropic.claude-opus-4-7", preserve_dots=False - ) == "global-anthropic-claude-opus-4-7" + ) == "global.anthropic.claude-opus-4-7" def test_bare_foundation_model_id_preserved(self): """Non-inference-profile Bedrock IDs @@ -422,12 +420,11 @@ class TestBedrockBuildAnthropicKwargsEndToEnd: f"{kwargs['model']!r}" ) - def test_bedrock_model_mangled_without_preserve_dots(self): - """Inverse canary: without the flag, ``build_anthropic_kwargs`` - still produces the broken form — so the fix in - ``_anthropic_preserve_dots`` is the load-bearing piece that - wires ``preserve_dots=True`` through to this builder for the - Bedrock case.""" + def test_bedrock_model_preserved_without_preserve_dots(self): + """Bedrock inference profile IDs survive ``build_anthropic_kwargs`` + even without ``preserve_dots=True`` -- the prefix auto-detection + in ``normalize_model_name`` is the load-bearing piece. + Regression for #12295.""" from agent.anthropic_adapter import build_anthropic_kwargs kwargs = build_anthropic_kwargs( model="global.anthropic.claude-opus-4-7", @@ -437,4 +434,62 @@ class TestBedrockBuildAnthropicKwargsEndToEnd: reasoning_config=None, preserve_dots=False, ) - assert kwargs["model"] == "global-anthropic-claude-opus-4-7" + assert kwargs["model"] == "global.anthropic.claude-opus-4-7" + + +class TestBedrockModelIdDetection: + """Tests for ``_is_bedrock_model_id`` and the auto-detection that + makes ``normalize_model_name`` preserve dots for Bedrock IDs + regardless of ``preserve_dots``. Regression for #12295.""" + + def test_bare_bedrock_id_detected(self): + from agent.anthropic_adapter import _is_bedrock_model_id + assert _is_bedrock_model_id("anthropic.claude-opus-4-7") is True + + def test_regional_us_prefix_detected(self): + from agent.anthropic_adapter import _is_bedrock_model_id + assert _is_bedrock_model_id("us.anthropic.claude-sonnet-4-5-v1:0") is True + + def test_regional_global_prefix_detected(self): + from agent.anthropic_adapter import _is_bedrock_model_id + assert _is_bedrock_model_id("global.anthropic.claude-opus-4-7") is True + + def test_regional_eu_prefix_detected(self): + from agent.anthropic_adapter import _is_bedrock_model_id + assert _is_bedrock_model_id("eu.anthropic.claude-sonnet-4-6") is True + + def test_openrouter_format_not_detected(self): + from agent.anthropic_adapter import _is_bedrock_model_id + assert _is_bedrock_model_id("claude-opus-4.6") is False + + def test_bare_claude_not_detected(self): + from agent.anthropic_adapter import _is_bedrock_model_id + assert _is_bedrock_model_id("claude-opus-4-7") is False + + def test_bare_bedrock_id_preserved_without_flag(self): + """The primary bug from #12295: ``anthropic.claude-opus-4-7`` + sent to bedrock-mantle via auxiliary clients that don't pass + ``preserve_dots=True``.""" + from agent.anthropic_adapter import normalize_model_name + assert normalize_model_name( + "anthropic.claude-opus-4-7", preserve_dots=False + ) == "anthropic.claude-opus-4-7" + + def test_openrouter_dots_still_converted(self): + """Non-Bedrock dotted model names must still be converted.""" + from agent.anthropic_adapter import normalize_model_name + assert normalize_model_name("claude-opus-4.6") == "claude-opus-4-6" + + def test_bare_bedrock_id_survives_build_kwargs(self): + """End-to-end: bare Bedrock ID through ``build_anthropic_kwargs`` + without ``preserve_dots=True`` -- the auxiliary client path.""" + from agent.anthropic_adapter import build_anthropic_kwargs + kwargs = build_anthropic_kwargs( + model="anthropic.claude-opus-4-7", + messages=[{"role": "user", "content": "hi"}], + tools=None, + max_tokens=1024, + reasoning_config=None, + preserve_dots=False, + ) + assert kwargs["model"] == "anthropic.claude-opus-4-7"