{"repo":"NousResearch/hermes-agent","public_id":"7164","display_label":"dot-to-hyphen model name normalization causes breakage","summary":"Multiple bugs and fixes centered on a model name normalization function that converts dots to hyphens, which breaks model IDs for Bedrock inference profiles, custom providers, and third-party proxies. Members include bug reports, allowlist expansions, narrower normalization scope, and a proposed opt-in config flag.","subsystem":"agent","counts":{"members":43,"open_issues":4,"open_issues_distinct":4,"open_prs":4,"closed_issues":13,"merged_prs":4,"attempt_prs":22},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":12793},"resolution":{"state":"partially_resolved","by":"pr-12793,pr-15184,pr-17454"},"freshness":{"state":"verified","last_verified_at":"2026-05-04T10:09:25+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.05616832779623475},"fix_edges":[{"issue_number":11976,"pr_number":12793,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":12295,"pr_number":15184,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":12727,"pr_number":15184,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":13061,"pr_number":17454,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":13919,"pr_number":15184,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":15619,"pr_number":17454,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":16156,"pr_number":19660,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":17171,"pr_number":17454,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":7164,"title":"normalize_model_name() breaks model names for custom base URLs","url":null,"state":"open"},{"kind":"issue","number":11464,"title":"[Bug]: custom anthropic proxy rewrites z-ai/glm-5.1 to z-ai/glm-5-1","url":null,"state":"closed"},{"kind":"issue","number":11976,"title":"[Bug]: Bedrock inference profile model ID dots mangled to hyphens → HTTP 400 \"invalid model identifier\"","url":null,"state":"closed"},{"kind":"issue","number":12295,"title":"Bedrock Claude inference-profile IDs get dot-collapsed to hyphens -> BadRequestError 400","url":null,"state":"closed"},{"kind":"issue","number":12727,"title":"[Bug]: Bedrock inference profile IDs broken by normalize_model_name()","url":null,"state":"closed"},{"kind":"issue","number":13059,"title":"custom provider的model id被normalize_model_name错误修改","url":null,"state":"closed"},{"kind":"issue","number":13061,"title":"[Bug] normalize_model_name rewrites custom provider model IDs, breaking models with dots (e.g. z-ai/glm-5.1 via zenmux)","url":null,"state":"closed"},{"kind":"issue","number":13816,"title":"Bedrock Mantle proxy: model name dots mangled to dashes (404)","url":null,"state":"closed"},{"kind":"issue","number":13953,"title":"[Bug]: Allow users to opt into preserve_dots for third-party Bedrock-style proxies","url":null,"state":"closed"},{"kind":"issue","number":14575,"title":"Custom provider model names with dots are incorrectly converted to hyphens","url":null,"state":"closed"},{"kind":"issue","number":15619,"title":"Xiaomi/MiMo models require preserve_dots=True to keep model name format","url":null,"state":"closed"},{"kind":"issue","number":16156,"title":"[Bug]: _anthropic_preserve_dots() whitelist breaks dot-versioned model names for unlisted providers (e.g. Xiaomi MiMo)","url":null,"state":"closed"},{"kind":"issue","number":16417,"title":"[Bug]: custom anthropic_messages providers can rewrite model names by replacing '.' with '-'","url":null,"state":"open"},{"kind":"issue","number":17452,"title":"custom_providers with anthropic_messages mangles dotted model names → 502 from local proxies","url":null,"state":"closed"},{"kind":"issue","number":19239,"title":"Xiaomi TokenPlan Anthropic-compatible endpoint should preserve dots in MiMo model IDs","url":null,"state":"closed"},{"kind":"issue","number":22196,"title":"bug: Anthropic provider converts dots to hyphens for ALL models, breaks third-party Anthropic-compatible APIs (e.g. Xiaomi MiMo)","url":null,"state":"open"},{"kind":"issue","number":62231,"title":"Custom Anthropic-compatible providers need an opt-in to preserve dotted model ids","url":null,"state":"open"},{"kind":"pr","number":7157,"title":"fix: preserve dots in model names for custom base URLs","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#8794","number":8794}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":11480,"title":"fix(agent): preserve dotted ZAI models on custom anthropic proxies","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"7141cda967c489d1fec0e17740222db0e6ac6331","number":0},{"kind":"pr","value":"#13061","number":13061}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":11992,"title":"fix(run_agent): preserve dotted Bedrock inference-profile model IDs (#11976)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#12793","number":12793}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":12297,"title":"fix(anthropic_adapter): preserve dots in Bedrock Claude inference-profile IDs","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#12336","number":12336}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":12336,"title":"fix: preserve dots in Bedrock inference-profile IDs (#12295)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"1cf1016e7","number":0},{"kind":"commit","value":"f2fba4f9a","number":0},{"kind":"pr","value":"#11976","number":11976},{"kind":"pr","value":"#12295","number":12295},{"kind":"pr","value":"#12577","number":12577},{"kind":"pr","value":"#12578","number":12578},{"kind":"pr","value":"#13112","number":13112}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":12759,"title":"fix(anthropic): preserve dots in Bedrock inference profile IDs","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":12793,"title":"fix(run_agent): preserve dotted Bedrock inference-profile model IDs (#11976)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["run_agent.py","tests/agent/test_bedrock_integration.py"],"fix_hunks":[{"file":"run_agent.py","hunks":["@@ -6894,11 +6894,31 @@ def _anthropic_preserve_dots(self) -> bool:\n         Alibaba/DashScope keeps dots (e.g. qwen3.5-plus).\n         MiniMax keeps dots (e.g. MiniMax-M2.7).\n         OpenCode Go/Zen keeps dots for non-Claude models (e.g. minimax-m2.5-free).\n-        ZAI/Zhipu keeps dots (e.g. glm-4.7, glm-5.1).\"\"\"\n-        if (getattr(self, \"provider\", \"\") or \"\").lower() in {\"alibaba\", \"minimax\", \"minimax-cn\", \"opencode-go\", \"opencode-zen\", \"zai\"}:\n+        ZAI/Zhipu keeps dots (e.g. glm-4.7, glm-5.1).\n+        AWS Bedrock uses dotted inference-profile IDs\n+        (e.g. ``global.anthropic.claude-opus-4-7``,\n+        ``us.anthropic.claude-sonnet-4-5-20250929-v1:0``) and rejects\n+        the hyphenated form with\n+        ``HTTP 400 The provided model identifier is invalid``.\n+        Regression for #11976; mirrors the opencode-go fix for #5211\n+        (commit f77be22c), which extended this same allowlist.\"\"\"\n+        if (getattr(self, \"provider\", \"\") or \"\").lower() in {\n+            \"alibaba\", \"minimax\", \"minimax-cn\",\n+            \"opencode-go\", \"opencode-zen\",\n+            \"zai\", \"bedrock\",\n+        }:\n             return True\n         base = (getattr(self, \"base_url\", \"\") or \"\").lower()\n-        return \"dashscope\" in base or \"aliyuncs\" in base or \"minimax\" in base or \"opencode.ai/zen/\" in base or \"bigmodel.cn\" in base\n+        return (\n+            \"dashscope\" in base\n+            or \"aliyuncs\" in base\n+            or \"minimax\" in base\n+            or \"opencode.ai/zen/\" in base\n+            or \"bigmodel.cn\" in base\n+            # AWS Bedrock runtime endpoints — defense-in-depth when\n+            # ``provider`` is unset but ``base_url`` still names Bedrock.\n+            or \"bedrock-runtime.\" in base\n+        )\n \n     def _is_qwen_portal(self) -> bool:\n         \"\"\"Return True when the base URL targets Qwen Portal.\"\"\""]},{"file":"tests/agent/test_bedrock_integration.py","hunks":["@@ -267,3 +267,174 @@ def test_bedrock_in_all_extra(self):\n         from pathlib import Path\n         content = (Path(__file__).parent.parent.parent / \"pyproject.toml\").read_text()\n         assert '\"hermes-agent[bedrock]\"' in content\n+\n+\n+# ---------------------------------------------------------------------------\n+# Model ID dot preservation — regression for #11976\n+# ---------------------------------------------------------------------------\n+# AWS Bedrock inference-profile model IDs embed structural dots:\n+#\n+#   global.anthropic.claude-opus-4-7\n+#   us.anthropic.claude-sonnet-4-5-20250929-v1:0\n+#   apac.anthropic.claude-haiku-4-5\n+#\n+# ``agent.anthropic_adapter.normalize_model_name`` converts dots to hyphens\n+# unless the caller opts in via ``preserve_dots=True``.  Before this fix,\n+# ``AIAgent._anthropic_preserve_dots`` returned False for the ``bedrock``\n+# provider, so Claude-on-Bedrock requests went out with\n+# ``global-anthropic-claude-opus-4-7`` (all dots mangled to hyphens) and\n+# Bedrock rejected them with:\n+#\n+#   HTTP 400: The provided model identifier is invalid.\n+#\n+# The fix adds ``bedrock`` to the preserve-dots provider allowlist and\n+# ``bedrock-runtime.`` to the base-URL heuristic, mirroring the shape of\n+# the opencode-go fix for #5211 (commit f77be22c), which extended this\n+# same allowlist.\n+\n+\n+class TestBedrockPreserveDotsFlag:\n+    \"\"\"``AIAgent._anthropic_preserve_dots`` must return True on Bedrock so\n+    inference-profile IDs survive the normalize step intact.\"\"\"\n+\n+    def test_bedrock_provider_preserves_dots(self):\n+        from types import SimpleNamespace\n+        agent = SimpleNamespace(provider=\"bedrock\", base_url=\"\")\n+        from run_agent import AIAgent\n+        assert AIAgent._anthropic_preserve_dots(agent) is True\n+\n+    def test_bedrock_runtime_us_east_1_url_preserves_dots(self):\n+        \"\"\"Defense-in-depth: even without an explicit ``provider=\"bedrock\"``,\n+        a ``bedrock-runtime.us-east-1.amazonaws.com`` base URL must not\n+        mangle dots.\"\"\"\n+        from types import SimpleNamespace\n+        agent = SimpleNamespace(\n+            provider=\"custom\",\n+            base_url=\"https://bedrock-runtime.us-east-1.amazonaws.com\",\n+        )\n+        from run_agent import AIAgent\n+        assert AIAgent._anthropic_preserve_dots(agent) is True\n+\n+    def test_bedrock_runtime_ap_northeast_2_url_preserves_dots(self):\n+        \"\"\"Reporter-reported region (ap-northeast-2) exercises the same\n+        base-URL heuristic.\"\"\"\n+        from types import SimpleNamespace\n+        agent = SimpleNamespace(\n+            provider=\"custom\",\n+            base_url=\"https://bedrock-runtime.ap-northeast-2.amazonaws.com\",\n+        )\n+        from run_agent import AIAgent\n+        assert AIAgent._anthropic_preserve_dots(agent) is True\n+\n+    def test_non_bedrock_aws_url_does_not_preserve_dots(self):\n+        \"\"\"Unrelated AWS endpoints (e.g. ``s3.us-east-1.amazonaws.com``)\n+        must not accidentally activate the dot-preservation heuristic —\n+        the heuristic is scoped to the ``bedrock-runtime.`` substring\n+        specifically.\"\"\"\n+        from types import SimpleNamespace\n+        agent = SimpleNamespace(\n+            provider=\"custom\",\n+            base_url=\"https://s3.us-east-1.amazonaws.com\",\n+        )\n+        from run_agent import AIAgent\n+        assert AIAgent._anthropic_preserve_dots(agent) is False\n+\n+    def test_anthropic_native_still_does_not_preserve_dots(self):\n+        \"\"\"Canary: adding Bedrock to the allowlist must not weaken the\n+        existing Anthropic native behaviour — ``claude-sonnet-4.6`` still\n+        becomes ``claude-sonnet-4-6`` for the Anthropic API.\"\"\"\n+        from types import SimpleNamespace\n+        agent = SimpleNamespace(provider=\"anthropic\", base_url=\"https://api.anthropic.com\")\n+        from run_agent import AIAgent\n+        assert AIAgent._anthropic_preserve_dots(agent) is False\n+\n+\n+class TestBedrockModelNameNormalization:\n+    \"\"\"End-to-end: ``normalize_model_name`` + the preserve-dots flag\n+    reproduce the exact production request shape for each Bedrock model\n+    family, confirming the fix resolves the reporter's HTTP 400.\"\"\"\n+\n+    def test_global_anthropic_inference_profile_preserved(self):\n+        \"\"\"The reporter's exact model ID.\"\"\"\n+        from agent.anthropic_adapter import normalize_model_name\n+        assert normalize_model_name(\n+            \"global.anthropic.claude-opus-4-7\", preserve_dots=True\n+        ) == \"global.anthropic.claude-opus-4-7\"\n+\n+    def test_us_anthropic_dated_inference_profile_preserved(self):\n+        \"\"\"Regional + dated Sonnet inference profile.\"\"\"\n+        from agent.anthropic_adapter import normalize_model_name\n+        assert normalize_model_name(\n+            \"us.anthropic.claude-sonnet-4-5-20250929-v1:0\",\n+            preserve_dots=True,\n+        ) == \"us.anthropic.claude-sonnet-4-5-20250929-v1:0\"\n+\n+    def test_apac_anthropic_haiku_inference_profile_preserved(self):\n+        \"\"\"APAC inference profile — same structural-dot shape.\"\"\"\n+        from agent.anthropic_adapter import normalize_model_name\n+        assert normalize_model_name(\n+            \"apac.anthropic.claude-haiku-4-5\", preserve_dots=True\n+        ) == \"apac.anthropic.claude-haiku-4-5\"\n+\n+    def test_preserve_false_mangles_as_documented(self):\n+        \"\"\"Canary: with ``preserve_dots=False`` the function still\n+        produces the broken all-hyphen form — this is the shape that\n+        Bedrock rejected and that the fix avoids.  Keeping this test\n+        locks in the existing behaviour of ``normalize_model_name`` so a\n+        future refactor doesn't accidentally decouple the knob from its\n+        effect.\"\"\"\n+        from agent.anthropic_adapter import normalize_model_name\n+        assert normalize_model_name(\n+            \"global.anthropic.claude-opus-4-7\", preserve_dots=False\n+        ) == \"global-anthropic-claude-opus-4-7\"\n+\n+    def test_bare_foundation_model_id_preserved(self):\n+        \"\"\"Non-inference-profile Bedrock IDs\n+        (e.g. ``anthropic.claude-3-5-sonnet-20241022-v2:0``) use dots as\n+        vendor separators and must also survive intact under\n+        ``preserve_dots=True``.\"\"\"\n+        from agent.anthropic_adapter import normalize_model_name\n+        assert normalize_model_name(\n+            \"anthropic.claude-3-5-sonnet-20241022-v2:0\",\n+            preserve_dots=True,\n+        ) == \"anthropic.claude-3-5-sonnet-20241022-v2:0\"\n+\n+\n+class TestBedrockBuildAnthropicKwargsEndToEnd:\n+    \"\"\"Integration: calling ``build_anthropic_kwargs`` with a Bedrock-\n+    shaped model ID and ``preserve_dots=True`` produces the unmangled\n+    model string in the outgoing kwargs — the exact body sent to the\n+    ``bedrock-runtime.`` endpoint.  This is the integration-level\n+    regression for the reporter's HTTP 400.\"\"\"\n+\n+    def test_bedrock_inference_profile_survives_build_kwargs(self):\n+        from agent.anthropic_adapter import build_anthropic_kwargs\n+        kwargs = build_anthropic_kwargs(\n+            model=\"global.anthropic.claude-opus-4-7\",\n+            messages=[{\"role\": \"user\", \"content\": \"hi\"}],\n+            tools=None,\n+            max_tokens=1024,\n+            reasoning_config=None,\n+            preserve_dots=True,\n+        )\n+        assert kwargs[\"model\"] == \"global.anthropic.claude-opus-4-7\", (\n+            \"Bedrock inference-profile ID was mangled in build_anthropic_kwargs: \"\n+            f\"{kwargs['model']!r}\"\n+        )\n+\n+    def test_bedrock_model_mangled_without_preserve_dots(self):\n+        \"\"\"Inverse canary: without the flag, ``build_anthropic_kwargs``\n+        still produces the broken form — so the fix in\n+        ``_anthropic_preserve_dots`` is the load-bearing piece that\n+        wires ``preserve_dots=True`` through to this builder for the\n+        Bedrock case.\"\"\"\n+        from agent.anthropic_adapter import build_anthropic_kwargs\n+        kwargs = build_anthropic_kwargs(\n+            model=\"global.anthropic.claude-opus-4-7\",\n+            messages=[{\"role\": \"user\", \"content\": \"hi\"}],\n+            tools=None,\n+            max_tokens=1024,\n+            reasoning_config=None,\n+            preserve_dots=False,\n+        )\n+        assert kwargs[\"model\"] == \"global-anthropic-claude-opus-4-7\"\n"]}]},{"kind":"pr","number":13068,"title":"fix(agent): preserve dots in custom provider model IDs (#13061)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"7141cda967c489d1fec0e17740222db0e6ac6331","number":0},{"kind":"pr","value":"#17454","number":17454},{"kind":"pr","value":"#13061","number":13061}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":13112,"title":"fix: preserve Bedrock inference profile ID namespace separators","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"f2fba4f9a","number":0},{"kind":"pr","value":"#14664","number":14664},{"kind":"pr","value":"#15184","number":15184},{"kind":"pr","value":"#14664","number":14664},{"kind":"pr","value":"#12727","number":12727},{"kind":"pr","value":"#13112","number":13112},{"kind":"pr","value":"#14664","number":14664}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":13227,"title":"fix(anthropic): preserve Bedrock inference profile IDs in normalize_model_name()","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"f2fba4f9a","number":0},{"kind":"commit","value":"f2fba4f9a19726f8cf3ba11d4b630bdf462e38ce","number":0},{"kind":"pr","value":"#12295","number":12295},{"kind":"pr","value":"#12759","number":12759},{"kind":"pr","value":"#13112","number":13112},{"kind":"pr","value":"#12578","number":12578},{"kind":"pr","value":"#12759","number":12759},{"kind":"pr","value":"#13112","number":13112}],"category":"merged_via","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":13818,"title":"fix(agent): preserve dots in model names for Bedrock Mantle endpoints","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#13821","number":13821}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":13821,"title":"fix(bedrock): preserve dots in model names for Bedrock Mantle proxy","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"f2fba4f9a","number":0},{"kind":"commit","value":"f2fba4f9a19726f8cf3ba11d4b630bdf462e38ce","number":0},{"kind":"pr","value":"#13816","number":13816}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":13998,"title":"fix(run_agent): add model.preserve_dots config flag for Bedrock-style proxies","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"f2fba4f9a","number":0},{"kind":"commit","value":"f2fba4f9a","number":0},{"kind":"pr","value":"#12295","number":12295},{"kind":"pr","value":"#13953","number":13953}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":14576,"title":"fix: preserve dots in model names for custom providers with anthropic_messages","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"7141cda967c489d1fec0e17740222db0e6ac6331","number":0}],"category":"partial_fix","implication":"needs_llm"},"merged":false},{"kind":"pr","number":14664,"title":"fix(anthropic): auto-detect Bedrock model IDs in normalize_model_name (#12295)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"f2fba4f9a","number":0},{"kind":"commit","value":"f2fba4f9a19726f8cf3ba11d4b630bdf462e38ce","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":15184,"title":"fix(bedrock): consolidated — model-ID detection, context length priority, aux aws_sdk, stale-connection eviction, interrupt rebuild","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/anthropic_adapter.py","agent/auxiliary_client.py","agent/bedrock_adapter.py","agent/model_metadata.py","run_agent.py","scripts/release.py","tests/agent/test_bedrock_adapter.py","tests/agent/test_bedrock_integration.py","tests/agent/test_model_metadata.py"],"fix_hunks":[{"file":"agent/anthropic_adapter.py","hunks":["@@ -986,6 +986,26 @@ def read_hermes_oauth_credentials() -> Optional[Dict[str, Any]]:\n # ---------------------------------------------------------------------------\n \n \n+def _is_bedrock_model_id(model: str) -> bool:\n+    \"\"\"Detect AWS Bedrock model IDs that use dots as namespace separators.\n+\n+    Bedrock model IDs come in two forms:\n+    - Bare:    ``anthropic.claude-opus-4-7``\n+    - Regional (inference profiles): ``us.anthropic.claude-sonnet-4-5-v1:0``\n+\n+    In both cases the dots separate namespace components, not version\n+    numbers, and must be preserved verbatim for the Bedrock API.\n+    \"\"\"\n+    lower = model.lower()\n+    # Regional inference-profile prefixes\n+    if any(lower.startswith(p) for p in (\"global.\", \"us.\", \"eu.\", \"ap.\", \"jp.\")):\n+        return True\n+    # Bare Bedrock model IDs: provider.model-family\n+    if lower.startswith(\"anthropic.\"):\n+        return True\n+    return False\n+\n+\n def normalize_model_name(model: str, preserve_dots: bool = False) -> str:\n     \"\"\"Normalize a model name for the Anthropic API.\n ","@@ -993,11 +1013,19 @@ def normalize_model_name(model: str, preserve_dots: bool = False) -> str:\n     - Converts dots to hyphens in version numbers (OpenRouter uses dots,\n       Anthropic uses hyphens: claude-opus-4.6 → claude-opus-4-6), unless\n       preserve_dots is True (e.g. for Alibaba/DashScope: qwen3.5-plus).\n+    - Preserves Bedrock model IDs (``anthropic.claude-opus-4-7``) and\n+      regional inference profiles (``us.anthropic.claude-*``) whose dots\n+      are namespace separators, not version separators.\n     \"\"\"\n     lower = model.lower()\n     if lower.startswith(\"anthropic/\"):\n         model = model[len(\"anthropic/\"):]\n     if not preserve_dots:\n+        # Bedrock model IDs use dots as namespace separators\n+        # (e.g. \"anthropic.claude-opus-4-7\", \"us.anthropic.claude-*\").\n+        # These must not be converted to hyphens.  See issue #12295.\n+        if _is_bedrock_model_id(model):\n+            return model\n         # OpenRouter uses dots for version separators (claude-opus-4.6),\n         # Anthropic uses hyphens (claude-opus-4-6). Convert dots to hyphens.\n         model = model.replace(\".\", \"-\")"]},{"file":"agent/auxiliary_client.py","hunks":["@@ -1993,6 +1993,39 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = \"\"):\n                        \"directly supported\", provider)\n         return None, None\n \n+    elif pconfig.auth_type == \"aws_sdk\":\n+        # AWS SDK providers (Bedrock) — use the Anthropic Bedrock client via\n+        # boto3's credential chain (IAM roles, SSO, env vars, instance metadata).\n+        try:\n+            from agent.bedrock_adapter import has_aws_credentials, resolve_bedrock_region\n+            from agent.anthropic_adapter import build_anthropic_bedrock_client\n+        except ImportError:\n+            logger.warning(\"resolve_provider_client: bedrock requested but \"\n+                           \"boto3 or anthropic SDK not installed\")\n+            return None, None\n+\n+        if not has_aws_credentials():\n+            logger.debug(\"resolve_provider_client: bedrock requested but \"\n+                         \"no AWS credentials found\")\n+            return None, None\n+\n+        region = resolve_bedrock_region()\n+        default_model = \"anthropic.claude-haiku-4-5-20251001-v1:0\"\n+        final_model = _normalize_resolved_model(model or default_model, provider)\n+        try:\n+            real_client = build_anthropic_bedrock_client(region)\n+        except ImportError as exc:\n+            logger.warning(\"resolve_provider_client: cannot create Bedrock \"\n+                           \"client: %s\", exc)\n+            return None, None\n+        client = AnthropicAuxiliaryClient(\n+            real_client, final_model, api_key=\"[redacted:secret-assignment]\",\n+            base_url=f\"https://bedrock-runtime.{region}.amazonaws.com\",\n+        )\n+        logger.debug(\"resolve_provider_client: bedrock (%s, %s)\", final_model, region)\n+        return (_to_async_client(client, final_model) if async_mode\n+                else (client, final_model))\n+\n     elif pconfig.auth_type in (\"oauth_device_code\", \"oauth_external\"):\n         # OAuth providers — route through their specific try functions\n         if provider == \"nous\":"]},{"file":"agent/bedrock_adapter.py","hunks":["@@ -87,6 +87,114 @@ def reset_client_cache():\n     _bedrock_control_client_cache.clear()\n \n \n+def invalidate_runtime_client(region: str) -> bool:\n+    \"\"\"Evict the cached ``bedrock-runtime`` client for a single region.\n+\n+    Per-region counterpart to :func:`reset_client_cache`. Used by the converse\n+    call wrappers to discard clients whose underlying HTTP connection has\n+    gone stale, so the next call allocates a fresh client (with a fresh\n+    connection pool) instead of reusing a dead socket.\n+\n+    Returns True if a cached entry was evicted, False if the region was not\n+    cached.\n+    \"\"\"\n+    existed = region in _bedrock_runtime_client_cache\n+    _bedrock_runtime_client_cache.pop(region, None)\n+    return existed\n+\n+\n+# ---------------------------------------------------------------------------\n+# Stale-connection detection\n+# ---------------------------------------------------------------------------\n+#\n+# boto3 caches its HTTPS connection pool inside the client object. When a\n+# pooled connection is killed out from under us (NAT timeout, VPN flap,\n+# server-side TCP RST, proxy idle cull, etc.), the next use surfaces as\n+# one of a handful of low-level exceptions — most commonly\n+# ``botocore.exceptions.ConnectionClosedError`` or\n+# ``urllib3.exceptions.ProtocolError``. urllib3 also trips an internal\n+# ``assert`` in a couple of paths (connection pool state checks, chunked\n+# response readers) which bubbles up as a bare ``AssertionError`` with an\n+# empty ``str(exc)``.\n+#\n+# In all of these cases the client is the problem, not the request: retrying\n+# with the same cached client reproduces the failure until the process\n+# restarts. The fix is to evict the region's cached client so the next\n+# attempt builds a new one.\n+\n+_STALE_LIB_MODULE_PREFIXES = (\n+    \"urllib3.\",\n+    \"botocore.\",\n+    \"boto3.\",\n+)\n+\n+\n+def _traceback_frames_modules(exc: BaseException):\n+    \"\"\"Yield ``__name__``-style module strings for each frame in exc's traceback.\"\"\"\n+    tb = getattr(exc, \"__traceback__\", None)\n+    while tb is not None:\n+        frame = tb.tb_frame\n+        module = frame.f_globals.get(\"__name__\", \"\")\n+        yield module or \"\"\n+        tb = tb.tb_next\n+\n+\n+def is_stale_connection_error(exc: BaseException) -> bool:\n+    \"\"\"Return True if ``exc`` indicates a dead/stale Bedrock HTTP connection.\n+\n+    Matches:\n+      * ``botocore.exceptions.ConnectionError`` and subclasses\n+        (``ConnectionClosedError``, ``EndpointConnectionError``,\n+        ``ReadTimeoutError``, ``ConnectTimeoutError``).\n+      * ``urllib3.exceptions.ProtocolError`` / ``NewConnectionError`` /\n+        ``ConnectionError`` (best-effort import — urllib3 is a transitive\n+        dependency of botocore so it is always available in practice).\n+      * Bare ``AssertionError`` raised from a frame inside urllib3, botocore,\n+        or boto3. These are internal-invariant failures (typically triggered\n+        by corrupted connection-pool state after a dropped socket) and are\n+        recoverable by swapping the client.\n+\n+    Non-library ``AssertionError``s (from application code or tests) are\n+    intentionally not matched — only library-internal asserts signal stale\n+    connection state.\n+    \"\"\"\n+    # botocore: the canonical signal — HTTPClientError is the umbrella for\n+    # ConnectionClosedError, ReadTimeoutError, EndpointConnectionError,\n+    # ConnectTimeoutError, and ProxyConnectionError. ConnectionError covers\n+    # the same family via a different branch of the hierarchy.\n+    try:\n+        from botocore.exceptions import (\n+            ConnectionError as BotoConnectionError,\n+            HTTPClientError,\n+        )\n+        botocore_errors: tuple = (BotoConnectionError, HTTPClientError)\n+    except ImportError:  # pragma: no cover — botocore always present with boto3\n+        botocore_errors = ()\n+    if botocore_errors and isinstance(exc, botocore_errors):\n+        return True\n+\n+    # urllib3: low-level transport failures\n+    try:\n+        from urllib3.exceptions import (\n+            ProtocolError,\n+            NewConnectionError,\n+            ConnectionError as Urllib3ConnectionError,\n+        )\n+        urllib3_errors = (ProtocolError, NewConnectionError, Urllib3ConnectionError)\n+    except ImportError:  # pragma: no cover\n+        urllib3_errors = ()\n+    if urllib3_errors and isinstance(exc, urllib3_errors):\n+        return True\n+\n+    # Library-internal AssertionError (urllib3 / botocore / boto3)\n+    if isinstance(exc, AssertionError):\n+        for module in _traceback_frames_modules(exc):\n+            if any(module.startswith(prefix) for prefix in _STALE_LIB_MODULE_PREFIXES):\n+                return True\n+\n+    return False\n+\n+\n # ---------------------------------------------------------------------------\n # AWS credential detection\n # ---------------------------------------------------------------------------","@@ -787,7 +895,17 @@ def call_converse(\n         guardrail_config=guardrail_config,\n     )\n \n-    response = client.converse(**kwargs)\n+    try:\n+        response = client.converse(**kwargs)\n+    except Exception as exc:\n+        if is_stale_connection_error(exc):\n+            logger.warning(\n+                \"bedrock: stale-connection error on converse(region=%s, model=%s): \"\n+                \"%s — evicting cached client so the next call reconnects.\",\n+                region, model, type(exc).__name__,\n+            )\n+            invalidate_runtime_client(region)\n+        raise\n     return normalize_converse_response(response)\n \n ","@@ -819,7 +937,17 @@ def call_converse_stream(\n         guardrail_config=guardrail_config,\n     )\n \n-    response = client.converse_stream(**kwargs)\n+    try:\n+        response = client.converse_stream(**kwargs)\n+    except Exception as exc:\n+        if is_stale_connection_error(exc):\n+            logger.warning(\n+                \"bedrock: stale-connection error on converse_stream(region=%s, \"\n+                \"model=%s): %s — evicting cached client so the next call reconnects.\",\n+                region, model, type(exc).__name__,\n+            )\n+            invalidate_runtime_client(region)\n+        raise\n     return normalize_converse_stream_events(response)\n \n "]},{"file":"agent/model_metadata.py","hunks":["@@ -1199,6 +1199,7 @@ def get_model_context_length(\n     Resolution order:\n     0. Explicit config override (model.context_length or custom_providers per-model)\n     1. Persistent cache (previously discovered via probing)\n+    1b. AWS Bedrock static table (must precede custom-endpoint probe)\n     2. Active endpoint metadata (/models for explicit custom endpoints)\n     3. Local server query (for local endpoints)\n     4. Anthropic /v1/models API (API-key users only, not OAuth)","@@ -1237,6 +1238,26 @@ def get_model_context_length(\n             else:\n                 return cached\n \n+    # 1b. AWS Bedrock — use static context length table.\n+    # Bedrock's ListFoundationModels API doesn't expose context window sizes,\n+    # so we maintain a curated table in bedrock_adapter.py that reflects\n+    # AWS-imposed limits (e.g. 200K for Claude models vs 1M on the native\n+    # Anthropic API).  This must run BEFORE the custom-endpoint probe at\n+    # step 2 — bedrock-runtime.<region>.amazonaws.com is not in\n+    # _URL_TO_PROVIDER, so it would otherwise be treated as a custom endpoint,\n+    # fail the /models probe (Bedrock doesn't expose that shape), and fall\n+    # back to the 128K default before reaching the original step 4b branch.\n+    if provider == \"bedrock\" or (\n+        base_url\n+        and base_url_hostname(base_url).startswith(\"bedrock-runtime.\")\n+        and base_url_host_matches(base_url, \"amazonaws.com\")\n+    ):\n+        try:\n+            from agent.bedrock_adapter import get_bedrock_context_length\n+            return get_bedrock_context_length(model)\n+        except ImportError:\n+            pass  # boto3 not installed — fall through to generic resolution\n+\n     # 2. Active endpoint metadata for truly custom/unknown endpoints.\n     # Known providers (Copilot, OpenAI, Anthropic, etc.) skip this — their\n     # /models endpoint may report a provider-imposed limit (e.g. Copilot","@@ -1282,19 +1303,7 @@ def get_model_context_length(\n         if ctx:\n             return ctx\n \n-    # 4b. AWS Bedrock — use static context length table.\n-    # Bedrock's ListFoundationModels doesn't expose context window sizes,\n-    # so we maintain a curated table in bedrock_adapter.py.\n-    if provider == \"bedrock\" or (\n-        base_url\n-        and base_url_hostname(base_url).startswith(\"bedrock-runtime.\")\n-        and base_url_host_matches(base_url, \"amazonaws.com\")\n-    ):\n-        try:\n-            from agent.bedrock_adapter import get_bedrock_context_length\n-            return get_bedrock_context_length(model)\n-        except ImportError:\n-            pass  # boto3 not installed — fall through to generic resolution\n+    # 4b. (Bedrock handled earlier at step 1b — before custom-endpoint probe.)\n \n     # 5. Provider-aware lookups (before generic OpenRouter cache)\n     # These are provider-specific and take priority over the generic OR cache,"]},{"file":"run_agent.py","hunks":["@@ -5432,6 +5432,26 @@ def _anthropic_messages_create(self, api_kwargs: dict):\n             self._try_refresh_anthropic_client_credentials()\n         return self._anthropic_client.messages.create(**api_kwargs)\n \n+    def _rebuild_anthropic_client(self) -> None:\n+        \"\"\"Rebuild the Anthropic client after an interrupt or stale call.\n+\n+        Handles both direct Anthropic and Bedrock-hosted Anthropic models\n+        correctly — rebuilding with the Bedrock SDK when provider is bedrock,\n+        rather than always falling back to build_anthropic_client() which\n+        requires a direct Anthropic API key.\n+        \"\"\"\n+        if getattr(self, \"provider\", None) == \"bedrock\":\n+            from agent.anthropic_adapter import build_anthropic_bedrock_client\n+            region = getattr(self, \"_bedrock_region\", \"us-east-1\") or \"us-east-1\"\n+            self._anthropic_client = build_anthropic_bedrock_client(region)\n+        else:\n+            from agent.anthropic_adapter import build_anthropic_client\n+            self._anthropic_client = build_anthropic_client(\n+                self._anthropic_api_key,\n+                getattr(self, \"_anthropic_base_url\", None),\n+                timeout=get_provider_request_timeout(self.provider, self.model),\n+            )\n+\n     def _interruptible_api_call(self, api_kwargs: dict):\n         \"\"\"\n         Run the API call in a background thread so the main conversation loop","@@ -5467,12 +5487,21 @@ def _call():\n                     # bedrock responses like chat_completions responses.\n                     from agent.bedrock_adapter import (\n                         _get_bedrock_runtime_client,\n+                        invalidate_runtime_client,\n+                        is_stale_connection_error,\n                         normalize_converse_response,\n                     )\n                     region = api_kwargs.pop(\"__bedrock_region__\", \"us-east-1\")\n                     api_kwargs.pop(\"__bedrock_converse__\", None)\n                     client = _get_bedrock_runtime_client(region)\n-                    raw_response = client.converse(**api_kwargs)\n+                    try:\n+                        raw_response = client.converse(**api_kwargs)\n+                    except Exception as _bedrock_exc:\n+                        # Evict the cached client on stale-connection failures\n+                        # so the outer retry loop builds a fresh client/pool.\n+                        if is_stale_connection_error(_bedrock_exc):\n+                            invalidate_runtime_client(region)\n+                        raise\n                     result[\"response\"] = normalize_converse_response(raw_response)\n                 else:\n                     request_client_holder[\"client\"] = self._create_request_openai_client(reason=\"chat_completion_request\")","@@ -5530,14 +5559,8 @@ def _call():\n                 )\n                 try:\n                     if self.api_mode == \"anthropic_messages\":\n-                        from agent.anthropic_adapter import build_anthropic_client\n-\n                         self._anthropic_client.close()\n-                        self._anthropic_client = build_anthropic_client(\n-                            self._anthropic_api_key,\n-                            getattr(self, \"_anthropic_base_url\", None),\n-                            timeout=get_provider_request_timeout(self.provider, self.model),\n-                        )\n+                        self._rebuild_anthropic_client()\n                     else:\n                         rc = request_client_holder.get(\"client\")\n                         if rc is not None:","@@ -5562,14 +5585,8 @@ def _call():\n                 # seed future retries.\n                 try:\n                     if self.api_mode == \"anthropic_messages\":\n-                        from agent.anthropic_adapter import build_anthropic_client\n-\n                         self._anthropic_client.close()\n-                        self._anthropic_client = build_anthropic_client(\n-                            self._anthropic_api_key,\n-                            getattr(self, \"_anthropic_base_url\", None),\n-                            timeout=get_provider_request_timeout(self.provider, self.model),\n-                        )\n+                        self._rebuild_anthropic_client()\n                     else:\n                         request_client = request_client_holder.get(\"client\")\n                         if request_client is not None:","@@ -5725,12 +5742,21 @@ def _bedrock_call():\n                 try:\n                     from agent.bedrock_adapter import (\n                         _get_bedrock_runtime_client,\n+                        invalidate_runtime_client,\n+                        is_stale_connection_error,\n                         stream_converse_with_callbacks,\n                     )\n                     region = api_kwargs.pop(\"__bedrock_region__\", \"us-east-1\")\n                     api_kwargs.pop(\"__bedrock_converse__\", None)\n                     client = _get_bedrock_runtime_client(region)\n-                    raw_response = client.converse_stream(**api_kwargs)\n+                    try:\n+                        raw_response = client.converse_stream(**api_kwargs)\n+                    except Exception as _bedrock_exc:\n+                        # Evict the cached client on stale-connection failures\n+                        # so the outer retry loop builds a fresh client/pool.\n+                        if is_stale_connection_error(_bedrock_exc):\n+                            invalidate_runtime_client(region)\n+                        raise\n \n                     def _on_text(text):\n                         _fire_first()","@@ -6410,14 +6436,8 @@ def _call():\n             if self._interrupt_requested:\n                 try:\n                     if self.api_mode == \"anthropic_messages\":\n-                        from agent.anthropic_adapter import build_anthropic_client\n-\n                         self._anthropic_client.close()\n-                        self._anthropic_client = build_anthropic_client(\n-                            self._anthropic_api_key,\n-                            getattr(self, \"_anthropic_base_url\", None),\n-                            timeout=get_provider_request_timeout(self.provider, self.model),\n-                        )\n+                        self._rebuild_anthropic_client()\n                     else:\n                         request_client = request_client_holder.get(\"client\")\n                         if request_client is not None:"]},{"file":"scripts/release.py","hunks":["@@ -201,6 +201,9 @@\n     \"[redacted:email]\": \"5park1e\",\n     \"[redacted:email]\": \"5park1e\",\n     \"[redacted:email]\": \"innocarpe\",\n+    \"[redacted:email]\": \"qike-ms\",\n+    \"[redacted:email]\": \"AndreKurait\",\n+    \"[redacted:email]\": \"bsgdigital\",\n     \"[redacted:email]\": \"nummanali\",\n     \"[redacted:email]\": \"whitehatjr1001\",\n     \"[redacted:email]\": \"0xNyk\","]},{"file":"tests/agent/test_bedrock_adapter.py","hunks":["@@ -1230,3 +1230,210 @@ def test_real_text_preserved(self):\n         from agent.bedrock_adapter import _convert_content_to_converse\n         blocks = _convert_content_to_converse(\"Hello\")\n         assert blocks[0][\"text\"] == \"Hello\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Stale-connection detection and per-region client invalidation\n+# ---------------------------------------------------------------------------\n+\n+class TestInvalidateRuntimeClient:\n+    \"\"\"Per-region eviction used to discard dead/stale bedrock-runtime clients.\"\"\"\n+\n+    def test_evicts_only_the_target_region(self):\n+        from agent.bedrock_adapter import (\n+            _bedrock_runtime_client_cache,\n+            invalidate_runtime_client,\n+            reset_client_cache,\n+        )\n+        reset_client_cache()\n+        _bedrock_runtime_client_cache[\"us-east-1\"] = \"dead-client\"\n+        _bedrock_runtime_client_cache[\"us-west-2\"] = \"live-client\"\n+\n+        evicted = invalidate_runtime_client(\"us-east-1\")\n+\n+        assert evicted is True\n+        assert \"us-east-1\" not in _bedrock_runtime_client_cache\n+        assert _bedrock_runtime_client_cache[\"us-west-2\"] == \"live-client\"\n+\n+    def test_returns_false_when_region_not_cached(self):\n+        from agent.bedrock_adapter import invalidate_runtime_client, reset_client_cache\n+        reset_client_cache()\n+        assert invalidate_runtime_client(\"eu-west-1\") is False\n+\n+\n+class TestIsStaleConnectionError:\n+    \"\"\"Classifier that decides whether an exception warrants client eviction.\"\"\"\n+\n+    def test_detects_botocore_connection_closed_error(self):\n+        from agent.bedrock_adapter import is_stale_connection_error\n+        from botocore.exceptions import ConnectionClosedError\n+        exc = ConnectionClosedError(endpoint_url=\"https://bedrock.example\")\n+        assert is_stale_connection_error(exc) is True\n+\n+    def test_detects_botocore_endpoint_connection_error(self):\n+        from agent.bedrock_adapter import is_stale_connection_error\n+        from botocore.exceptions import EndpointConnectionError\n+        exc = EndpointConnectionError(endpoint_url=\"https://bedrock.example\")\n+        assert is_stale_connection_error(exc) is True\n+\n+    def test_detects_botocore_read_timeout(self):\n+        from agent.bedrock_adapter import is_stale_connection_error\n+        from botocore.exceptions import ReadTimeoutError\n+        exc = ReadTimeoutError(endpoint_url=\"https://bedrock.example\")\n+        assert is_stale_connection_error(exc) is True\n+\n+    def test_detects_urllib3_protocol_error(self):\n+        from agent.bedrock_adapter import is_stale_connection_error\n+        from urllib3.exceptions import ProtocolError\n+        exc = ProtocolError(\"Connection broken\")\n+        assert is_stale_connection_error(exc) is True\n+\n+    def test_detects_library_internal_assertion_error(self):\n+        \"\"\"A bare AssertionError raised from inside urllib3/botocore signals\n+        a corrupted connection-pool invariant and should trigger eviction.\"\"\"\n+        from agent.bedrock_adapter import is_stale_connection_error\n+\n+        # Fabricate an AssertionError whose traceback's last frame belongs\n+        # to a module named \"urllib3.connectionpool\". We do this by exec'ing\n+        # a tiny `assert False` under a fake globals dict — the resulting\n+        # frame's ``f_globals[\"__name__\"]`` is what the classifier inspects.\n+        fake_globals = {\"__name__\": \"urllib3.connectionpool\"}\n+        try:\n+            exec(\"def _boom():\\n    assert False\\n_boom()\", fake_globals)\n+        except AssertionError as exc:\n+            assert is_stale_connection_error(exc) is True\n+        else:\n+            pytest.fail(\"AssertionError not raised\")\n+\n+    def test_detects_botocore_internal_assertion_error(self):\n+        \"\"\"Same as above but for a frame inside the botocore namespace.\"\"\"\n+        from agent.bedrock_adapter import is_stale_connection_error\n+        fake_globals = {\"__name__\": \"botocore.httpsession\"}\n+        try:\n+            exec(\"def _boom():\\n    assert False\\n_boom()\", fake_globals)\n+        except AssertionError as exc:\n+            assert is_stale_connection_error(exc) is True\n+        else:\n+            pytest.fail(\"AssertionError not raised\")\n+\n+    def test_ignores_application_assertion_error(self):\n+        \"\"\"AssertionError from application code (not urllib3/botocore) should\n+        NOT be classified as stale — those are real test/code bugs.\"\"\"\n+        from agent.bedrock_adapter import is_stale_connection_error\n+        try:\n+            assert False, \"test-only\"  # noqa: B011\n+        except AssertionError as exc:\n+            assert is_stale_connection_error(exc) is False\n+\n+    def test_ignores_unrelated_exceptions(self):\n+        from agent.bedrock_adapter import is_stale_connection_error\n+        assert is_stale_connection_error(ValueError(\"bad input\")) is False\n+        assert is_stale_connection_error(KeyError(\"missing\")) is False\n+\n+\n+class TestCallConverseInvalidatesOnStaleError:\n+    \"\"\"call_converse / call_converse_stream evict the cached client when the\n+    boto3 call raises a stale-connection error — so the next invocation\n+    reconnects instead of reusing the dead socket.\"\"\"\n+\n+    def test_converse_evicts_client_on_stale_error(self):\n+        from agent.bedrock_adapter import (\n+            _bedrock_runtime_client_cache,\n+            call_converse,\n+            reset_client_cache,\n+        )\n+        from botocore.exceptions import ConnectionClosedError\n+\n+        reset_client_cache()\n+        dead_client = MagicMock()\n+        dead_client.converse.side_effect = ConnectionClosedError(\n+            endpoint_url=\"https://bedrock.example\",\n+        )\n+        _bedrock_runtime_client_cache[\"us-east-1\"] = dead_client\n+\n+        with pytest.raises(ConnectionClosedError):\n+            call_converse(\n+                region=\"us-east-1\",\n+                model=\"anthropic.claude-3-sonnet-20240229-v1:0\",\n+                messages=[{\"role\": \"user\", \"content\": \"hi\"}],\n+            )\n+\n+        assert \"us-east-1\" not in _bedrock_runtime_client_cache, (\n+            \"stale client should have been evicted so the retry reconnects\"\n+        )\n+\n+    def test_converse_stream_evicts_client_on_stale_error(self):\n+        from agent.bedrock_adapter import (\n+            _bedrock_runtime_client_cache,\n+            call_converse_stream,\n+            reset_client_cache,\n+        )\n+        from botocore.exceptions import ConnectionClosedError\n+\n+        reset_client_cache()\n+        dead_client = MagicMock()\n+        dead_client.converse_stream.side_effect = ConnectionClosedError(\n+            endpoint_url=\"https://bedrock.example\",\n+        )\n+        _bedrock_runtime_client_cache[\"us-east-1\"] = dead_client\n+\n+        with pytest.raises(ConnectionClosedError):\n+            call_converse_stream(\n+                region=\"us-east-1\",\n+                model=\"anthropic.claude-3-sonnet-20240229-v1:0\",\n+                messages=[{\"role\": \"user\", \"content\": \"hi\"}],\n+            )\n+\n+        assert \"us-east-1\" not in _bedrock_runtime_client_cache\n+\n+    def test_converse_does_not_evict_on_non_stale_error(self):\n+        \"\"\"Non-stale errors (e.g. ValidationException) leave the client cache alone.\"\"\"\n+        from agent.bedrock_adapter import (\n+            _bedrock_runtime_client_cache,\n+            call_converse,\n+            reset_client_cache,\n+        )\n+        from botocore.exceptions import ClientError\n+\n+        reset_client_cache()\n+        live_client = MagicMock()\n+        live_client.converse.side_effect = ClientError(\n+            error_response={\"Error\": {\"Code\": \"ValidationException\", \"Message\": \"bad\"}},\n+            operation_name=\"Converse\",\n+        )\n+        _bedrock_runtime_client_cache[\"us-east-1\"] = live_client\n+\n+        with pytest.raises(ClientError):\n+            call_converse(\n+                region=\"us-east-1\",\n+                model=\"anthropic.claude-3-sonnet-20240229-v1:0\",\n+                messages=[{\"role\": \"user\", \"content\": \"hi\"}],\n+            )\n+\n+        assert _bedrock_runtime_client_cache.get(\"us-east-1\") is live_client, (\n+            \"validation errors do not indicate a dead connection — keep the client\"\n+        )\n+\n+    def test_converse_leaves_successful_client_in_cache(self):\n+        from agent.bedrock_adapter import (\n+            _bedrock_runtime_client_cache,\n+            call_converse,\n+            reset_client_cache,\n+        )\n+\n+        reset_client_cache()\n+        live_client = MagicMock()\n+        live_client.converse.return_value = {\n+            \"output\": {\"message\": {\"role\": \"assistant\", \"content\": [{\"text\": \"hi\"}]}},\n+            \"stopReason\": \"end_turn\",\n+            \"usage\": {\"inputTokens\": 1, \"outputTokens\": 1, \"totalTokens\": 2},\n+        }\n+        _bedrock_runtime_client_cache[\"us-east-1\"] = live_client\n+\n+        call_converse(\n+            region=\"us-east-1\",\n+            model=\"anthropic.claude-3-sonnet-20240229-v1:0\",\n+            messages=[{\"role\": \"user\", \"content\": \"hi\"}],\n+        )\n+\n+        assert _bedrock_runtime_client_cache.get(\"us-east-1\") is live_client"]},{"file":"tests/agent/test_bedrock_integration.py","hunks":["@@ -376,17 +376,15 @@ def test_apac_anthropic_haiku_inference_profile_preserved(self):\n             \"apac.anthropic.claude-haiku-4-5\", preserve_dots=True\n         ) == \"apac.anthropic.claude-haiku-4-5\"\n \n-    def test_preserve_false_mangles_as_documented(self):\n-        \"\"\"Canary: with ``preserve_dots=False`` the function still\n-        produces the broken all-hyphen form — this is the shape that\n-        Bedrock rejected and that the fix avoids.  Keeping this test\n-        locks in the existing behaviour of ``normalize_model_name`` so a\n-        future refactor doesn't accidentally decouple the knob from its\n-        effect.\"\"\"\n+    def test_bedrock_prefix_preserved_without_preserve_dots(self):\n+        \"\"\"Bedrock inference profile IDs are auto-detected by prefix and\n+        always returned unmangled -- ``preserve_dots`` is irrelevant for\n+        these IDs because the dots are namespace separators, not version\n+        separators.  Regression for #12295.\"\"\"\n         from agent.anthropic_adapter import normalize_model_name\n         assert normalize_model_name(\n             \"global.anthropic.claude-opus-4-7\", preserve_dots=False\n-        ) == \"global-anthropic-claude-opus-4-7\"\n+        ) == \"global.anthropic.claude-opus-4-7\"\n \n     def test_bare_foundation_model_id_preserved(self):\n         \"\"\"Non-inference-profile Bedrock IDs","@@ -422,12 +420,11 @@ def test_bedrock_inference_profile_survives_build_kwargs(self):\n             f\"{kwargs['model']!r}\"\n         )\n \n-    def test_bedrock_model_mangled_without_preserve_dots(self):\n-        \"\"\"Inverse canary: without the flag, ``build_anthropic_kwargs``\n-        still produces the broken form — so the fix in\n-        ``_anthropic_preserve_dots`` is the load-bearing piece that\n-        wires ``preserve_dots=True`` through to this builder for the\n-        Bedrock case.\"\"\"\n+    def test_bedrock_model_preserved_without_preserve_dots(self):\n+        \"\"\"Bedrock inference profile IDs survive ``build_anthropic_kwargs``\n+        even without ``preserve_dots=True`` -- the prefix auto-detection\n+        in ``normalize_model_name`` is the load-bearing piece.\n+        Regression for #12295.\"\"\"\n         from agent.anthropic_adapter import build_anthropic_kwargs\n         kwargs = build_anthropic_kwargs(\n             model=\"global.anthropic.claude-opus-4-7\",","@@ -437,4 +434,157 @@ def test_bedrock_model_mangled_without_preserve_dots(self):\n             reasoning_config=None,\n             preserve_dots=False,\n         )\n-        assert kwargs[\"model\"] == \"global-anthropic-claude-opus-4-7\"\n+        assert kwargs[\"model\"] == \"global.anthropic.claude-opus-4-7\"\n+\n+\n+class TestBedrockModelIdDetection:\n+    \"\"\"Tests for ``_is_bedrock_model_id`` and the auto-detection that\n+    makes ``normalize_model_name`` preserve dots for Bedrock IDs\n+    regardless of ``preserve_dots``.  Regression for #12295.\"\"\"\n+\n+    def test_bare_bedrock_id_detected(self):\n+        from agent.anthropic_adapter import _is_bedrock_model_id\n+        assert _is_bedrock_model_id(\"anthropic.claude-opus-4-7\") is True\n+\n+    def test_regional_us_prefix_detected(self):\n+        from agent.anthropic_adapter import _is_bedrock_model_id\n+        assert _is_bedrock_model_id(\"us.anthropic.claude-sonnet-4-5-v1:0\") is True\n+\n+    def test_regional_global_prefix_detected(self):\n+        from agent.anthropic_adapter import _is_bedrock_model_id\n+        assert _is_bedrock_model_id(\"global.anthropic.claude-opus-4-7\") is True\n+\n+    def test_regional_eu_prefix_detected(self):\n+        from agent.anthropic_adapter import _is_bedrock_model_id\n+        assert _is_bedrock_model_id(\"eu.anthropic.claude-sonnet-4-6\") is True\n+\n+    def test_openrouter_format_not_detected(self):\n+        from agent.anthropic_adapter import _is_bedrock_model_id\n+        assert _is_bedrock_model_id(\"claude-opus-4.6\") is False\n+\n+    def test_bare_claude_not_detected(self):\n+        from agent.anthropic_adapter import _is_bedrock_model_id\n+        assert _is_bedrock_model_id(\"claude-opus-4-7\") is False\n+\n+    def test_bare_bedrock_id_preserved_without_flag(self):\n+        \"\"\"The primary bug from #12295: ``anthropic.claude-opus-4-7``\n+        sent to bedrock-mantle via auxiliary clients that don't pass\n+        ``preserve_dots=True``.\"\"\"\n+        from agent.anthropic_adapter import normalize_model_name\n+        assert normalize_model_name(\n+            \"anthropic.claude-opus-4-7\", preserve_dots=False\n+        ) == \"anthropic.claude-opus-4-7\"\n+\n+    def test_openrouter_dots_still_converted(self):\n+        \"\"\"Non-Bedrock dotted model names must still be converted.\"\"\"\n+        from agent.anthropic_adapter import normalize_model_name\n+        assert normalize_model_name(\"claude-opus-4.6\") == \"claude-opus-4-6\"\n+\n+    def test_bare_bedrock_id_survives_build_kwargs(self):\n+        \"\"\"End-to-end: bare Bedrock ID through ``build_anthropic_kwargs``\n+        without ``preserve_dots=True`` -- the auxiliary client path.\"\"\"\n+        from agent.anthropic_adapter import build_anthropic_kwargs\n+        kwargs = build_anthropic_kwargs(\n+            model=\"anthropic.claude-opus-4-7\",\n+            messages=[{\"role\": \"user\", \"content\": \"hi\"}],\n+            tools=None,\n+            max_tokens=1024,\n+            reasoning_config=None,\n+            preserve_dots=False,\n+        )\n+        assert kwargs[\"model\"] == \"anthropic.claude-opus-4-7\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# auxiliary_client Bedrock resolution — fix for #13919\n+# ---------------------------------------------------------------------------\n+# Before the fix, resolve_provider_client(\"bedrock\", ...) fell through to the\n+# \"unhandled auth_type\" warning and returned (None, None), breaking all\n+# auxiliary tasks (compression, memory, summarization) for Bedrock users.\n+\n+\n+class TestAuxiliaryClientBedrockResolution:\n+    \"\"\"Verify resolve_provider_client handles Bedrock's aws_sdk auth type.\"\"\"\n+\n+    def test_bedrock_returns_client_with_credentials(self, monkeypatch):\n+        \"\"\"With valid AWS credentials, Bedrock should return a usable client.\"\"\"\n+        monkeypatch.setenv(\"AWS_ACCESS_KEY_ID\", \"[redacted:aws-access-key]\")\n+        monkeypatch.setenv(\"AWS_SECRET_ACCESS_KEY\", \"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\")\n+        monkeypatch.setenv(\"AWS_REGION\", \"us-west-2\")\n+\n+        mock_anthropic_bedrock = MagicMock()\n+        with patch(\"agent.anthropic_adapter.build_anthropic_bedrock_client\",\n+                   return_value=mock_anthropic_bedrock):\n+            from agent.auxiliary_client import resolve_provider_client, AnthropicAuxiliaryClient\n+            client, model = resolve_provider_client(\"bedrock\", None)\n+\n+        assert client is not None, (\n+            \"resolve_provider_client('bedrock') returned None — \"\n+            \"aws_sdk auth type is not handled\"\n+        )\n+        assert isinstance(client, AnthropicAuxiliaryClient)\n+        assert model is not None\n+        assert client.api_key == \"aws-sdk\"\n+        assert \"us-west-2\" in client.base_url\n+\n+    def test_bedrock_returns_none_without_credentials(self, monkeypatch):\n+        \"\"\"Without AWS credentials, Bedrock should return (None, None) gracefully.\"\"\"\n+        with patch(\"agent.bedrock_adapter.has_aws_credentials\", return_value=False):\n+            from agent.auxiliary_client import resolve_provider_client\n+            client, model = resolve_provider_client(\"bedrock\", None)\n+\n+        assert client is None\n+        assert model is None\n+\n+    def test_bedrock_uses_configured_region(self, monkeypatch):\n+        \"\"\"Bedrock client base_url should reflect AWS_REGION.\"\"\"\n+        monkeypatch.setenv(\"AWS_ACCESS_KEY_ID\", \"[redacted:aws-access-key]\")\n+        monkeypatch.setenv(\"AWS_SECRET_ACCESS_KEY\", \"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\")\n+        monkeypatch.setenv(\"AWS_REGION\", \"eu-central-1\")\n+\n+        with patch(\"agent.anthropic_adapter.build_anthropic_bedrock_client\",\n+                   return_value=MagicMock()):\n+            from agent.auxiliary_client import resolve_provider_client\n+            client, _ = resolve_provider_client(\"bedrock\", None)\n+\n+        assert client is not None\n+        assert \"eu-central-1\" in client.base_url\n+\n+    def test_bedrock_respects_explicit_model(self, monkeypatch):\n+        \"\"\"When caller passes an explicit model, it should be used.\"\"\"\n+        monkeypatch.setenv(\"AWS_ACCESS_KEY_ID\", \"[redacted:aws-access-key]\")\n+        monkeypatch.setenv(\"AWS_SECRET_ACCESS_KEY\", \"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\")\n+\n+        with patch(\"agent.anthropic_adapter.build_anthropic_bedrock_client\",\n+                   return_value=MagicMock()):\n+            from agent.auxiliary_client import resolve_provider_client\n+            _, model = resolve_provider_client(\n+                \"bedrock\", \"us.anthropic.claude-sonnet-4-5-20250929-v1:0\"\n+            )\n+\n+        assert \"claude-sonnet\" in model\n+\n+    def test_bedrock_async_mode(self, monkeypatch):\n+        \"\"\"Async mode should return an AsyncAnthropicAuxiliaryClient.\"\"\"\n+        monkeypatch.setenv(\"AWS_ACCESS_KEY_ID\", \"[redacted:aws-access-key]\")\n+        monkeypatch.setenv(\"AWS_SECRET_ACCESS_KEY\", \"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\")\n+\n+        with patch(\"agent.anthropic_adapter.build_anthropic_bedrock_client\",\n+                   return_value=MagicMock()):\n+            from agent.auxiliary_client import resolve_provider_client, AsyncAnthropicAuxiliaryClient\n+            client, model = resolve_provider_client(\"bedrock\", None, async_mode=True)\n+\n+        assert client is not None\n+        assert isinstance(client, AsyncAnthropicAuxiliaryClient)\n+\n+    def test_bedrock_default_model_is_haiku(self, monkeypatch):\n+        \"\"\"Default auxiliary model for Bedrock should be Haiku (fast, cheap).\"\"\"\n+        monkeypatch.setenv(\"AWS_ACCESS_KEY_ID\", \"[redacted:aws-access-key]\")\n+        monkeypatch.setenv(\"AWS_SECRET_ACCESS_KEY\", \"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\")\n+\n+        with patch(\"agent.anthropic_adapter.build_anthropic_bedrock_client\",\n+                   return_value=MagicMock()):\n+            from agent.auxiliary_client import resolve_provider_client\n+            _, model = resolve_provider_client(\"bedrock\", None)\n+\n+        assert \"haiku\" in model.lower()"]},{"file":"tests/agent/test_model_metadata.py","hunks":["@@ -588,6 +588,57 @@ def test_config_context_length_none_is_ignored(self, mock_fetch):\n         assert result == 200000\n \n \n+# =========================================================================\n+# Bedrock context resolution — must run BEFORE custom-endpoint probe\n+# =========================================================================\n+\n+class TestBedrockContextResolution:\n+    \"\"\"Regression tests for Bedrock context-length resolution order.\n+\n+    Bug: because ``bedrock-runtime.<region>.amazonaws.com`` is not listed in\n+    ``_URL_TO_PROVIDER``, ``_is_known_provider_base_url`` returned False and\n+    the custom-endpoint probe at step 2 ran first — fetching ``/models`` from\n+    Bedrock (which it doesn't serve), returning the 128K default-fallback\n+    before execution ever reached the Bedrock branch.\n+\n+    Fix: promote the Bedrock branch ahead of the custom-endpoint probe.\n+    \"\"\"\n+\n+    @patch(\"agent.model_metadata.fetch_endpoint_model_metadata\")\n+    def test_bedrock_provider_returns_static_table_before_probe(self, mock_fetch):\n+        \"\"\"provider='bedrock' resolves via static table, bypasses /models probe.\"\"\"\n+        ctx = get_model_context_length(\n+            \"anthropic.claude-opus-4-v1:0\",\n+            provider=\"bedrock\",\n+            base_url=\"https://bedrock-runtime.us-east-1.amazonaws.com\",\n+        )\n+        # Must return the static Bedrock table value (200K for Claude),\n+        # NOT DEFAULT_FALLBACK_CONTEXT (128K).\n+        assert ctx == 200000\n+        mock_fetch.assert_not_called()\n+\n+    @patch(\"agent.model_metadata.fetch_endpoint_model_metadata\")\n+    def test_bedrock_url_without_provider_hint(self, mock_fetch):\n+        \"\"\"bedrock-runtime host infers Bedrock even when provider is omitted.\"\"\"\n+        ctx = get_model_context_length(\n+            \"anthropic.claude-sonnet-4-v1:0\",\n+            base_url=\"https://bedrock-runtime.us-west-2.amazonaws.com\",\n+        )\n+        assert ctx == 200000\n+        mock_fetch.assert_not_called()\n+\n+    @patch(\"agent.model_metadata.fetch_endpoint_model_metadata\")\n+    def test_non_bedrock_url_still_probes(self, mock_fetch):\n+        \"\"\"Non-Bedrock hosts still reach the custom-endpoint probe.\"\"\"\n+        mock_fetch.return_value = {\"some-model\": {\"context_length\": 50000}}\n+        ctx = get_model_context_length(\n+            \"some-model\",\n+            base_url=\"https://api.example.com/v1\",\n+        )\n+        assert ctx == 50000\n+        assert mock_fetch.called\n+\n+\n # =========================================================================\n # _strip_provider_prefix — Ollama model:tag vs provider:model\n # =========================================================================\n"]}]},{"kind":"pr","number":15637,"title":"fix(model-normalize): add xiaomi to preserve-dots allowlist (#15619)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"7141cda96","number":0},{"kind":"commit","value":"9be3ab1a5","number":0},{"kind":"pr","value":"#17454","number":17454},{"kind":"pr","value":"#17454","number":17454},{"kind":"pr","value":"#15619","number":15619}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":15868,"title":"fix: add xiaomi provider to dots-preserve whitelist","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#15637","number":15637},{"kind":"pr","value":"#15619","number":15619}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":16157,"title":"fix(agent): preserve dots in model names for Xiaomi MiMo provider","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#19660","number":19660}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":16429,"title":"fix(agent): preserve dots in model names for custom providers","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#13068","number":13068}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":17454,"title":"fix(anthropic): narrow dot→hyphen normalization to Claude models only (#17171)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/anthropic_adapter.py"],"fix_hunks":[{"file":"agent/anthropic_adapter.py","hunks":["@@ -1076,9 +1076,12 @@ def normalize_model_name(model: str, preserve_dots: bool = False) -> str:\n         # These must not be converted to hyphens.  See issue #12295.\n         if _is_bedrock_model_id(model):\n             return model\n-        # OpenRouter uses dots for version separators (claude-opus-4.6),\n-        # Anthropic uses hyphens (claude-opus-4-6). Convert dots to hyphens.\n-        model = model.replace(\".\", \"-\")\n+        # Only convert dots to hyphens for Anthropic/Claude models.\n+        # Non-Anthropic models (gpt-5.4, gemini-2.5, etc.) use dots\n+        # as part of their canonical names.  See issue #17171.\n+        _lower = model.lower()\n+        if _lower.startswith(\"claude-\") or _lower.startswith(\"anthropic/\"):\n+            model = model.replace(\".\", \"-\")\n     return model\n \n \n"]}]},{"kind":"pr","number":17823,"title":"fix(custom_providers): allow opt-in preserve_model_dots for Anthropic-compatible local proxies (#17452)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"7141cda967c489d1fec0e17740222db0e6ac6331","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":19660,"title":"fix(agent): preserve dots in model names for Xiaomi MiMo provider","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["run_agent.py"],"fix_hunks":[{"file":"run_agent.py","hunks":["@@ -8203,6 +8203,7 @@ def _anthropic_preserve_dots(self) -> bool:\n         \"\"\"True when using an anthropic-compatible endpoint that preserves dots in model names.\n         Alibaba/DashScope keeps dots (e.g. qwen3.5-plus).\n         MiniMax keeps dots (e.g. MiniMax-M2.7).\n+        Xiaomi MiMo keeps dots (e.g. mimo-v2.5, mimo-v2.5-pro).\n         OpenCode Go/Zen keeps dots for non-Claude models (e.g. minimax-m2.5-free).\n         ZAI/Zhipu keeps dots (e.g. glm-4.7, glm-5.1).\n         AWS Bedrock uses dotted inference-profile IDs","@@ -8216,6 +8217,7 @@ def _anthropic_preserve_dots(self) -> bool:\n             \"alibaba\", \"minimax\", \"minimax-cn\",\n             \"opencode-go\", \"opencode-zen\",\n             \"zai\", \"bedrock\",\n+            \"xiaomi\",\n         }:\n             return True\n         base = (getattr(self, \"base_url\", \"\") or \"\").lower()","@@ -8225,6 +8227,7 @@ def _anthropic_preserve_dots(self) -> bool:\n             or \"minimax\" in base\n             or \"opencode.ai/zen/\" in base\n             or \"bigmodel.cn\" in base\n+            or \"xiaomimimo.com\" in base\n             # AWS Bedrock runtime endpoints — defense-in-depth when\n             # ``provider`` is unset but ``base_url`` still names Bedrock.\n             or \"bedrock-runtime.\" in base\n"]}]},{"kind":"pr","number":22246,"title":"fix(cli): only convert dots to hyphens for Claude models on Anthropic provider","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"4feb6f6168872a806e68e63e9e61edafa9f8ffed","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":40261,"title":"fix: only apply dots→hyphens normalization for Claude models on Anthropic provider","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#22246","number":22246},{"kind":"pr","value":"#22196","number":22196},{"kind":"pr","value":"#37056","number":37056}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":105699,"title":"fix(model_normalize): only normalize dots-to-hyphens for Claude models on anthropic provider","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#22246","number":22246},{"kind":"pr","value":"#22246","number":22246}],"category":"duplicate_of","implication":"duplicate"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}