dot-to-hyphen model name normalization causes breakage

NousResearch/hermes-agent · cluster 7164 · agent

Data as of

8 open items. Fix merged in #12793. Check it covers your case.

The short answer

4open issues
4open PRs
43items grouped
Known?
Yes. 43 related items grouped here, 13 issues already closed.
Fix event
A fix was merged to main in PR #12793. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PRs #12793, #15184, #17454. That is separate from the fix event.
Still open
4 issues and 4 PRs.

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.

Open issues4

  • Issue#7164normalize_model_name() breaks model names for custom base URLs
  • Issue#16417[Bug]: custom anthropic_messages providers can rewrite model names by replacing '.' with '-'
  • Issue#22196bug: Anthropic provider converts dots to hyphens for ALL models, breaks third-party Anthropic-compatible APIs (e.g. Xiaomi MiMo)
  • Issue#62231Custom Anthropic-compatible providers need an opt-in to preserve dotted model ids

Open PRs4

  • PR#14576fix: preserve dots in model names for custom providers with anthropic_messages
  • PR#16429fix(agent): preserve dots in model names for custom providers
  • PR#22246fix(cli): only convert dots to hyphens for Claude models on Anthropic provider
  • PR#40261fix: only apply dots→hyphens normalization for Claude models on Anthropic provider

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #12793 fix(run_agent): preserve dotted Bedrock inference-profile model IDs (#11976)

    merged to main

    files: run_agent.py, tests/agent/test_bedrock_integration.py

    Show the diff
    • run_agent.py
      @@ -6894,11 +6894,31 @@ def _anthropic_preserve_dots(self) -> bool:
               Alibaba/DashScope keeps dots (e.g. qwen3.5-plus).
               MiniMax keeps dots (e.g. MiniMax-M2.7).
               OpenCode Go/Zen keeps dots for non-Claude models (e.g. minimax-m2.5-free).
      -        ZAI/Zhipu keeps dots (e.g. glm-4.7, glm-5.1)."""
      -        if (getattr(self, "provider", "") or "").lower() in {"alibaba", "minimax", "minimax-cn", "opencode-go", "opencode-zen", "zai"}:
      +        ZAI/Zhipu keeps dots (e.g. glm-4.7, glm-5.1).
      +        AWS Bedrock uses dotted inference-profile IDs
      +        (e.g. ``global.anthropic.claude-opus-4-7``,
      +        ``us.anthropic.claude-sonnet-4-5-20250929-v1:0``) and rejects
      +        the hyphenated form with
      +        ``HTTP 400 The provided model identifier is invalid``.
      +        Regression for #11976; mirrors the opencode-go fix for #5211
      +        (commit f77be22c), which extended this same allowlist."""
      +        if (getattr(self, "provider", "") or "").lower() in {
      +            "alibaba", "minimax", "minimax-cn",
      +            "opencode-go", "opencode-zen",
      +            "zai", "bedrock",
      +        }:
                   return True
               base = (getattr(self, "base_url", "") or "").lower()
      -        return "dashscope" in base or "aliyuncs" in base or "minimax" in base or "opencode.ai/zen/" in base or "bigmodel.cn" in base
      +        return (
      +            "dashscope" in base
      +            or "aliyuncs" in base
      +            or "minimax" in base
      +            or "opencode.ai/zen/" in base
      +            or "bigmodel.cn" in base
      +            # AWS Bedrock runtime endpoints — defense-in-depth when
      +            # ``provider`` is unset but ``base_url`` still names Bedrock.
      +            or "bedrock-runtime." in base
      +        )
       
           def _is_qwen_portal(self) -> bool:
               """Return True when the base URL targets Qwen Portal."""
    • tests/agent/test_bedrock_integration.py
      @@ -267,3 +267,174 @@ def test_bedrock_in_all_extra(self):
               from pathlib import Path
               content = (Path(__file__).parent.parent.parent / "pyproject.toml").read_text()
               assert '"hermes-agent[bedrock]"' in content
      +
      +
      +# ---------------------------------------------------------------------------
      +# Model ID dot preservation — regression for #11976
      +# ---------------------------------------------------------------------------
      +# AWS Bedrock inference-profile model IDs embed structural dots:
      +#
      +#   global.anthropic.claude-opus-4-7
      +#   us.anthropic.claude-sonnet-4-5-20250929-v1:0
      +#   apac.anthropic.claude-haiku-4-5
      +#
      +# ``agent.anthropic_adapter.normalize_model_name`` converts dots to hyphens
      +# unless the caller opts in via ``preserve_dots=True``.  Before this fix,
      +# ``AIAgent._anthropic_preserve_dots`` returned False for the ``bedrock``
      +# provider, so Claude-on-Bedrock requests went out with
      +# ``global-anthropic-claude-opus-4-7`` (all dots mangled to hyphens) and
      +# Bedrock rejected them with:
      +#
      +#   HTTP 400: The provided model identifier is invalid.
      +#
      +# The fix adds ``bedrock`` to the preserve-dots provider allowlist and
      +# ``bedrock-runtime.`` to the base-URL heuristic, mirroring the shape of
      +# the opencode-go fix for #5211 (commit f77be22c), which extended this
      +# same allowlist.
      +
      +
      +class TestBedrockPreserveDotsFlag:
      +    """``AIAgent._anthropic_preserve_dots`` must return True on Bedrock so
      +    inference-profile IDs survive the normalize step intact."""
      +
      +    def test_bedrock_provider_preserves_dots(self):
      +        from types import SimpleNamespace
      +        agent = SimpleNamespace(provider="bedrock", base_url="")
      +        from run_agent import AIAgent
      +        assert AIAgent._anthropic_preserve_dots(agent) is True
      +
      +    def test_bedrock_runtime_us_east_1_url_preserves_dots(self):
      +        """Defense-in-depth: even without an explicit ``provider="bedrock"``,
      +        a ``bedrock-runtime.us-east-1.amazonaws.com`` base URL must not
      +        mangle dots."""
      +        from types import SimpleNamespace
      +        agent = SimpleNamespace(
      +            provider="custom",
      +            base_url="https://bedrock-runtime.us-east-1.amazonaws.com",
      +        )
      +        from run_agent import AIAgent
      +        assert AIAgent._anthropic_preserve_dots(agent) is True
      +
      +    def test_bedrock_runtime_ap_northeast_2_url_preserves_dots(self):
      +        """Reporter-reported region (ap-northeast-2) exercises the same
      +        base-URL heuristic."""
      +        from types import SimpleNamespace
      +        agent = SimpleNamespace(
      +            provider="custom",
      +            base_url="https://bedrock-runtime.ap-northeast-2.amazonaws.com",
      +        )
      +        from run_agent import AIAgent
      +        assert AIAgent._anthropic_preserve_dots(agent) is True
      +
      +    def test_non_bedrock_aws_url_does_not_preserve_dots(self):
      +        """Unrelated AWS endpoints (e.g. ``s3.us-east-1.amazonaws.com``)
      +        must not accidentally activate the dot-preservation heuristic —
      +        the heuristic is scoped to the ``bedrock-runtime.`` substring
      +        specifically."""
      +        from types import SimpleNamespace
      +        agent = SimpleNamespace(
      +            provider="custom",
      +            base_url="https://s3.us-east-1.amazonaws.com",
      +        )
      +        from run_agent import AIAgent
      +        assert AIAgent._anthropic_preserve_dots(agent) is False
      +
      +    def test_anthropic_native_still_does_not_preserve_dots(self):
      +        """Canary: adding Bedrock to the allowlist must not weaken the
      +        existing Anthropic native behaviour — ``claude-sonnet-4.6`` still
      +        becomes ``claude-sonnet-4-6`` for the Anthropic API."""
      +        from types import SimpleNamespace
      +        agent = SimpleNamespace(provider="anthropic", base_url="https://api.anthropic.com")
      +        from run_agent import AIAgent
      +        assert AIAgent._anthropic_preserve_dots(agent) is False
      +
      +
      +class TestBedrockModelNameNormalization:
      +    """End-to-end: ``normalize_model_name`` + the preserve-dots flag
      +    reproduce the exact production request shape for each Bedrock model
      +    family, confirming the fix resolves the reporter's HTTP 400."""
      +
      +    def test_global_anthropic_inference_profile_preserved(self):
      +        """The reporter's exact model ID."""
      +        from agent.anthropic_adapter import normalize_model_name
      +        assert normalize_model_name(
      +            "global.anthropic.claude-opus-4-7", preserve_dots=True
      +        ) == "global.anthropic.claude-opus-4-7"
      +
      +    def test_us_anthropic_dated_inference_profile_preserved(self):
      +        """Regional + dated Sonnet inference profile."""
      +        from agent.anthropic_adapter import normalize_model_name
      +        assert normalize_model_name(
      +            "us.anthropic.claude-sonnet-4-5-20250929-v1:0",
      +            preserve_dots=True,
      +        ) == "us.anthropic.claude-sonnet-4-5-20250929-v1:0"
      +
      +    def test_apac_anthropic_haiku_inference_profile_preserved(self):
      +        """APAC inference profile — same structural-dot shape."""
      +        from agent.anthropic_adapter import normalize_model_name
      +        assert normalize_model_name(
      +            "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."""
      +        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"
      +
      +    def test_bare_foundation_model_id_preserved(self):
      +        """Non-inference-profile Bedrock IDs
      +        (e.g. ``anthropic.claude-3-5-sonnet-20241022-v2:0``) use dots as
      +        vendor separators and must also survive intact under
      +        ``preserve_dots=True``."""
      +        from agent.anthropic_adapter import normalize_model_name
      +        assert normalize_model_name(
      +            "anthropic.claude-3-5-sonnet-20241022-v2:0",
      +            preserve_dots=True,
      +        ) == "anthropic.claude-3-5-sonnet-20241022-v2:0"
      +
      +
      +class TestBedrockBuildAnthropicKwargsEndToEnd:
      +    """Integration: calling ``build_anthropic_kwargs`` with a Bedrock-
      +    shaped model ID and ``preserve_dots=True`` produces the unmangled
      +    model string in the outgoing kwargs — the exact body sent to the
      +    ``bedrock-runtime.`` endpoint.  This is the integration-level
      +    regression for the reporter's HTTP 400."""
      +
      +    def test_bedrock_inference_profile_survives_build_kwargs(self):
      +        from agent.anthropic_adapter import build_anthropic_kwargs
      +        kwargs = build_anthropic_kwargs(
      +            model="global.anthropic.claude-opus-4-7",
      +            messages=[{"role": "user", "content": "hi"}],
      +            tools=None,
      +            max_tokens=1024,
      +            reasoning_config=None,
      +            preserve_dots=True,
      +        )
      +        assert kwargs["model"] == "global.anthropic.claude-opus-4-7", (
      +            "Bedrock inference-profile ID was mangled in build_anthropic_kwargs: "
      +            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."""
      +        from agent.anthropic_adapter import build_anthropic_kwargs
      +        kwargs = build_anthropic_kwargs(
      +            model="global.anthropic.claude-opus-4-7",
      +            messages=[{"role": "user", "content": "hi"}],
      +            tools=None,
      +            max_tokens=1024,
      +            reasoning_config=None,
      +            preserve_dots=False,
      +        )
      +        assert kwargs["model"] == "global-anthropic-claude-opus-4-7"
  • #15184 fix(bedrock): consolidated — model-ID detection, context length priority, aux aws_sdk, stale-connection eviction, interrupt rebuild

    merged to main

    files: 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

    Show the diff
    • 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(".", "-")
    • agent/auxiliary_client.py
      @@ -1993,6 +1993,39 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = ""):
                              "directly supported", provider)
               return None, None
       
      +    elif pconfig.auth_type == "aws_sdk":
      +        # AWS SDK providers (Bedrock) — use the Anthropic Bedrock client via
      +        # boto3's credential chain (IAM roles, SSO, env vars, instance metadata).
      +        try:
      +            from agent.bedrock_adapter import has_aws_credentials, resolve_bedrock_region
      +            from agent.anthropic_adapter import build_anthropic_bedrock_client
      +        except ImportError:
      +            logger.warning("resolve_provider_client: bedrock requested but "
      +                           "boto3 or anthropic SDK not installed")
      +            return None, None
      +
      +        if not has_aws_credentials():
      +            logger.debug("resolve_provider_client: bedrock requested but "
      +                         "no AWS credentials found")
      +            return None, None
      +
      +        region = resolve_bedrock_region()
      +        default_model = "anthropic.claude-haiku-4-5-20251001-v1:0"
      +        final_model = _normalize_resolved_model(model or default_model, provider)
      +        try:
      +            real_client = build_anthropic_bedrock_client(region)
      +        except ImportError as exc:
      +            logger.warning("resolve_provider_client: cannot create Bedrock "
      +                           "client: %s", exc)
      +            return None, None
      +        client = AnthropicAuxiliaryClient(
      +            real_client, final_model, api_key="[redacted:secret-assignment]",
      +            base_url=f"https://bedrock-runtime.{region}.amazonaws.com",
      +        )
      +        logger.debug("resolve_provider_client: bedrock (%s, %s)", final_model, region)
      +        return (_to_async_client(client, final_model) if async_mode
      +                else (client, final_model))
      +
           elif pconfig.auth_type in ("oauth_device_code", "oauth_external"):
               # OAuth providers — route through their specific try functions
               if provider == "nous":
    • agent/bedrock_adapter.py
      @@ -87,6 +87,114 @@ def reset_client_cache():
           _bedrock_control_client_cache.clear()
       
       
      +def invalidate_runtime_client(region: str) -> bool:
      +    """Evict the cached ``bedrock-runtime`` client for a single region.
      +
      +    Per-region counterpart to :func:`reset_client_cache`. Used by the converse
      +    call wrappers to discard clients whose underlying HTTP connection has
      +    gone stale, so the next call allocates a fresh client (with a fresh
      +    connection pool) instead of reusing a dead socket.
      +
      +    Returns True if a cached entry was evicted, False if the region was not
      +    cached.
      +    """
      +    existed = region in _bedrock_runtime_client_cache
      +    _bedrock_runtime_client_cache.pop(region, None)
      +    return existed
      +
      +
      +# ---------------------------------------------------------------------------
      +# Stale-connection detection
      +# ---------------------------------------------------------------------------
      +#
      +# boto3 caches its HTTPS connection pool inside the client object. When a
      +# pooled connection is killed out from under us (NAT timeout, VPN flap,
      +# server-side TCP RST, proxy idle cull, etc.), the next use surfaces as
      +# one of a handful of low-level exceptions — most commonly
      +# ``botocore.exceptions.ConnectionClosedError`` or
      +# ``urllib3.exceptions.ProtocolError``. urllib3 also trips an internal
      +# ``assert`` in a couple of paths (connection pool state checks, chunked
      +# response readers) which bubbles up as a bare ``AssertionError`` with an
      +# empty ``str(exc)``.
      +#
      +# In all of these cases the client is the problem, not the request: retrying
      +# with the same cached client reproduces the failure until the process
      +# restarts. The fix is to evict the region's cached client so the next
      +# attempt builds a new one.
      +
      +_STALE_LIB_MODULE_PREFIXES = (
      +    "urllib3.",
      +    "botocore.",
      +    "boto3.",
      +)
      +
      +
      +def _traceback_frames_modules(exc: BaseException):
      +    """Yield ``__name__``-style module strings for each frame in exc's traceback."""
      +    tb = getattr(exc, "__traceback__", None)
      +    while tb is not None:
      +        frame = tb.tb_frame
      +        module = frame.f_globals.get("__name__", "")
      +        yield module or ""
      +        tb = tb.tb_next
      +
      +
      +def is_stale_connection_error(exc: BaseException) -> bool:
      +    """Return True if ``exc`` indicates a dead/stale Bedrock HTTP connection.
      +
      +    Matches:
      +      * ``botocore.exceptions.ConnectionError`` and subclasses
      +        (``ConnectionClosedError``, ``EndpointConnectionError``,
      +        ``ReadTimeoutError``, ``ConnectTimeoutError``).
      +      * ``urllib3.exceptions.ProtocolError`` / ``NewConnectionError`` /
      +        ``ConnectionError`` (best-effort import — urllib3 is a transitive
      +        dependency of botocore so it is always available in practice).
      +      * Bare ``AssertionError`` raised from a frame inside urllib3, botocore,
      +        or boto3. These are internal-invariant failures (typically triggered
      +        by corrupted connection-pool state after a dropped socket) and are
      +        recoverable by swapping the client.
      +
      +    Non-library ``AssertionError``s (from application code or tests) are
      +    intentionally not matched — only library-internal asserts signal stale
      +    connection state.
      +    """
      +    # botocore: the canonical signal — HTTPClientError is the umbrella for
      +    # ConnectionClosedError, ReadTimeoutError, EndpointConnectionError,
      +    # ConnectTimeoutError, and ProxyConnectionError. ConnectionError covers
      +    # the same family via a different branch of the hierarchy.
      +    try:
      +        from botocore.exceptions import (
      +            ConnectionError as BotoConnectionError,
      +            HTTPClientError,
      +        )
      +        botocore_errors: tuple = (BotoConnectionError, HTTPClientError)
      +    except ImportError:  # pragma: no cover — botocore always present with boto3
      +        botocore_errors = ()
      +    if botocore_errors and isinstance(exc, botocore_errors):
      +        return True
      +
      +    # urllib3: low-level transport failures
      +    try:
      +        from urllib3.exceptions import (
      +            ProtocolError,
      +            NewConnectionError,
      +            ConnectionError as Urllib3ConnectionError,
      +        )
      +        urllib3_errors = (ProtocolError, NewConnectionError, Urllib3ConnectionError)
      +    except ImportError:  # pragma: no cover
      +        urllib3_errors = ()
      +    if urllib3_errors and isinstance(exc, urllib3_errors):
      +        return True
      +
      +    # Library-internal AssertionError (urllib3 / botocore / boto3)
      +    if isinstance(exc, AssertionError):
      +        for module in _traceback_frames_modules(exc):
      +            if any(module.startswith(prefix) for prefix in _STALE_LIB_MODULE_PREFIXES):
      +                return True
      +
      +    return False
      +
      +
       # ---------------------------------------------------------------------------
       # AWS credential detection
       # ---------------------------------------------------------------------------
      @@ -787,7 +895,17 @@ def call_converse(
               guardrail_config=guardrail_config,
           )
       
      -    response = client.converse(**kwargs)
      +    try:
      +        response = client.converse(**kwargs)
      +    except Exception as exc:
      +        if is_stale_connection_error(exc):
      +            logger.warning(
      +                "bedrock: stale-connection error on converse(region=%s, model=%s): "
      +                "%s — evicting cached client so the next call reconnects.",
      +                region, model, type(exc).__name__,
      +            )
      +            invalidate_runtime_client(region)
      +        raise
           return normalize_converse_response(response)
       
       
      @@ -819,7 +937,17 @@ def call_converse_stream(
               guardrail_config=guardrail_config,
           )
       
      -    response = client.converse_stream(**kwargs)
      +    try:
      +        response = client.converse_stream(**kwargs)
      +    except Exception as exc:
      +        if is_stale_connection_error(exc):
      +            logger.warning(
      +                "bedrock: stale-connection error on converse_stream(region=%s, "
      +                "model=%s): %s — evicting cached client so the next call reconnects.",
      +                region, model, type(exc).__name__,
      +            )
      +            invalidate_runtime_client(region)
      +        raise
           return normalize_converse_stream_events(response)
       
       
    • agent/model_metadata.py
      @@ -1199,6 +1199,7 @@ def get_model_context_length(
           Resolution order:
           0. Explicit config override (model.context_length or custom_providers per-model)
           1. Persistent cache (previously discovered via probing)
      +    1b. AWS Bedrock static table (must precede custom-endpoint probe)
           2. Active endpoint metadata (/models for explicit custom endpoints)
           3. Local server query (for local endpoints)
           4. Anthropic /v1/models API (API-key users only, not OAuth)
      @@ -1237,6 +1238,26 @@ def get_model_context_length(
                   else:
                       return cached
       
      +    # 1b. AWS Bedrock — use static context length table.
      +    # Bedrock's ListFoundationModels API doesn't expose context window sizes,
      +    # so we maintain a curated table in bedrock_adapter.py that reflects
      +    # AWS-imposed limits (e.g. 200K for Claude models vs 1M on the native
      +    # Anthropic API).  This must run BEFORE the custom-endpoint probe at
      +    # step 2 — bedrock-runtime.<region>.amazonaws.com is not in
      +    # _URL_TO_PROVIDER, so it would otherwise be treated as a custom endpoint,
      +    # fail the /models probe (Bedrock doesn't expose that shape), and fall
      +    # back to the 128K default before reaching the original step 4b branch.
      +    if provider == "bedrock" or (
      +        base_url
      +        and base_url_hostname(base_url).startswith("bedrock-runtime.")
      +        and base_url_host_matches(base_url, "amazonaws.com")
      +    ):
      +        try:
      +            from agent.bedrock_adapter import get_bedrock_context_length
      +            return get_bedrock_context_length(model)
      +        except ImportError:
      +            pass  # boto3 not installed — fall through to generic resolution
      +
           # 2. Active endpoint metadata for truly custom/unknown endpoints.
           # Known providers (Copilot, OpenAI, Anthropic, etc.) skip this — their
           # /models endpoint may report a provider-imposed limit (e.g. Copilot
      @@ -1282,19 +1303,7 @@ def get_model_context_length(
               if ctx:
                   return ctx
       
      -    # 4b. AWS Bedrock — use static context length table.
      -    # Bedrock's ListFoundationModels doesn't expose context window sizes,
      -    # so we maintain a curated table in bedrock_adapter.py.
      -    if provider == "bedrock" or (
      -        base_url
      -        and base_url_hostname(base_url).startswith("bedrock-runtime.")
      -        and base_url_host_matches(base_url, "amazonaws.com")
      -    ):
      -        try:
      -            from agent.bedrock_adapter import get_bedrock_context_length
      -            return get_bedrock_context_length(model)
      -        except ImportError:
      -            pass  # boto3 not installed — fall through to generic resolution
      +    # 4b. (Bedrock handled earlier at step 1b — before custom-endpoint probe.)
       
           # 5. Provider-aware lookups (before generic OpenRouter cache)
           # These are provider-specific and take priority over the generic OR cache,
    • run_agent.py
      @@ -5432,6 +5432,26 @@ def _anthropic_messages_create(self, api_kwargs: dict):
                   self._try_refresh_anthropic_client_credentials()
               return self._anthropic_client.messages.create(**api_kwargs)
       
      +    def _rebuild_anthropic_client(self) -> None:
      +        """Rebuild the Anthropic client after an interrupt or stale call.
      +
      +        Handles both direct Anthropic and Bedrock-hosted Anthropic models
      +        correctly — rebuilding with the Bedrock SDK when provider is bedrock,
      +        rather than always falling back to build_anthropic_client() which
      +        requires a direct Anthropic API key.
      +        """
      +        if getattr(self, "provider", None) == "bedrock":
      +            from agent.anthropic_adapter import build_anthropic_bedrock_client
      +            region = getattr(self, "_bedrock_region", "us-east-1") or "us-east-1"
      +            self._anthropic_client = build_anthropic_bedrock_client(region)
      +        else:
      +            from agent.anthropic_adapter import build_anthropic_client
      +            self._anthropic_client = build_anthropic_client(
      +                self._anthropic_api_key,
      +                getattr(self, "_anthropic_base_url", None),
      +                timeout=get_provider_request_timeout(self.provider, self.model),
      +            )
      +
           def _interruptible_api_call(self, api_kwargs: dict):
               """
               Run the API call in a background thread so the main conversation loop
      @@ -5467,12 +5487,21 @@ def _call():
                           # bedrock responses like chat_completions responses.
                           from agent.bedrock_adapter import (
                               _get_bedrock_runtime_client,
      +                        invalidate_runtime_client,
      +                        is_stale_connection_error,
                               normalize_converse_response,
                           )
                           region = api_kwargs.pop("__bedrock_region__", "us-east-1")
                           api_kwargs.pop("__bedrock_converse__", None)
                           client = _get_bedrock_runtime_client(region)
      -                    raw_response = client.converse(**api_kwargs)
      +                    try:
      +                        raw_response = client.converse(**api_kwargs)
      +                    except Exception as _bedrock_exc:
      +                        # Evict the cached client on stale-connection failures
      +                        # so the outer retry loop builds a fresh client/pool.
      +                        if is_stale_connection_error(_bedrock_exc):
      +                            invalidate_runtime_client(region)
      +                        raise
                           result["response"] = normalize_converse_response(raw_response)
                       else:
                           request_client_holder["client"] = self._create_request_openai_client(reason="chat_completion_request")
      @@ -5530,14 +5559,8 @@ def _call():
                       )
                       try:
                           if self.api_mode == "anthropic_messages":
      -                        from agent.anthropic_adapter import build_anthropic_client
      -
                               self._anthropic_client.close()
      -                        self._anthropic_client = build_anthropic_client(
      -                            self._anthropic_api_key,
      -                            getattr(self, "_anthropic_base_url", None),
      -                            timeout=get_provider_request_timeout(self.provider, self.model),
      -                        )
      +                        self._rebuild_anthropic_client()
                           else:
                               rc = request_client_holder.get("client")
                               if rc is not None:
      @@ -5562,14 +5585,8 @@ def _call():
                       # seed future retries.
                       try:
                           if self.api_mode == "anthropic_messages":
      -                        from agent.anthropic_adapter import build_anthropic_client
      -
                               self._anthropic_client.close()
      -                        self._anthropic_client = build_anthropic_client(
      -                            self._anthropic_api_key,
      -                            getattr(self, "_anthropic_base_url", None),
      -                            timeout=get_provider_request_timeout(self.provider, self.model),
      -                        )
      +                        self._rebuild_anthropic_client()
                           else:
                               request_client = request_client_holder.get("client")
                               if request_client is not None:
      @@ -5725,12 +5742,21 @@ def _bedrock_call():
                       try:
                           from agent.bedrock_adapter import (
                               _get_bedrock_runtime_client,
      +                        invalidate_runtime_client,
      +                        is_stale_connection_error,
                               stream_converse_with_callbacks,
                           )
                           region = api_kwargs.pop("__bedrock_region__", "us-east-1")
                           api_kwargs.pop("__bedrock_converse__", None)
                           client = _get_bedrock_runtime_client(region)
      -                    raw_response = client.converse_stream(**api_kwargs)
      +                    try:
      +                        raw_response = client.converse_stream(**api_kwargs)
      +                    except Exception as _bedrock_exc:
      +                        # Evict the cached client on stale-connection failures
      +                        # so the outer retry loop builds a fresh client/pool.
      +                        if is_stale_connection_error(_bedrock_exc):
      +                            invalidate_runtime_client(region)
      +                        raise
       
                           def _on_text(text):
                               _fire_first()
      @@ -6410,14 +6436,8 @@ def _call():
                   if self._interrupt_requested:
                       try:
                           if self.api_mode == "anthropic_messages":
      -                        from agent.anthropic_adapter import build_anthropic_client
      -
                               self._anthropic_client.close()
      -                        self._anthropic_client = build_anthropic_client(
      -                            self._anthropic_api_key,
      -                            getattr(self, "_anthropic_base_url", None),
      -                            timeout=get_provider_request_timeout(self.provider, self.model),
      -                        )
      +                        self._rebuild_anthropic_client()
                           else:
                               request_client = request_client_holder.get("client")
                               if request_client is not None:
    • scripts/release.py
      @@ -201,6 +201,9 @@
           "[redacted:email]": "5park1e",
           "[redacted:email]": "5park1e",
           "[redacted:email]": "innocarpe",
      +    "[redacted:email]": "qike-ms",
      +    "[redacted:email]": "AndreKurait",
      +    "[redacted:email]": "bsgdigital",
           "[redacted:email]": "nummanali",
           "[redacted:email]": "whitehatjr1001",
           "[redacted:email]": "0xNyk",
    • tests/agent/test_bedrock_adapter.py
      @@ -1230,3 +1230,210 @@ def test_real_text_preserved(self):
               from agent.bedrock_adapter import _convert_content_to_converse
               blocks = _convert_content_to_converse("Hello")
               assert blocks[0]["text"] == "Hello"
      +
      +
      +# ---------------------------------------------------------------------------
      +# Stale-connection detection and per-region client invalidation
      +# ---------------------------------------------------------------------------
      +
      +class TestInvalidateRuntimeClient:
      +    """Per-region eviction used to discard dead/stale bedrock-runtime clients."""
      +
      +    def test_evicts_only_the_target_region(self):
      +        from agent.bedrock_adapter import (
      +            _bedrock_runtime_client_cache,
      +            invalidate_runtime_client,
      +            reset_client_cache,
      +        )
      +        reset_client_cache()
      +        _bedrock_runtime_client_cache["us-east-1"] = "dead-client"
      +        _bedrock_runtime_client_cache["us-west-2"] = "live-client"
      +
      +        evicted = invalidate_runtime_client("us-east-1")
      +
      +        assert evicted is True
      +        assert "us-east-1" not in _bedrock_runtime_client_cache
      +        assert _bedrock_runtime_client_cache["us-west-2"] == "live-client"
      +
      +    def test_returns_false_when_region_not_cached(self):
      +        from agent.bedrock_adapter import invalidate_runtime_client, reset_client_cache
      +        reset_client_cache()
      +        assert invalidate_runtime_client("eu-west-1") is False
      +
      +
      +class TestIsStaleConnectionError:
      +    """Classifier that decides whether an exception warrants client eviction."""
      +
      +    def test_detects_botocore_connection_closed_error(self):
      +        from agent.bedrock_adapter import is_stale_connection_error
      +        from botocore.exceptions import ConnectionClosedError
      +        exc = ConnectionClosedError(endpoint_url="https://bedrock.example")
      +        assert is_stale_connection_error(exc) is True
      +
      +    def test_detects_botocore_endpoint_connection_error(self):
      +        from agent.bedrock_adapter import is_stale_connection_error
      +        from botocore.exceptions import EndpointConnectionError
      +        exc = EndpointConnectionError(endpoint_url="https://bedrock.example")
      +        assert is_stale_connection_error(exc) is True
      +
      +    def test_detects_botocore_read_timeout(self):
      +        from agent.bedrock_adapter import is_stale_connection_error
      +        from botocore.exceptions import ReadTimeoutError
      +        exc = ReadTimeoutError(endpoint_url="https://bedrock.example")
      +        assert is_stale_connection_error(exc) is True
      +
      +    def test_detects_urllib3_protocol_error(self):
      +        from agent.bedrock_adapter import is_stale_connection_error
      +        from urllib3.exceptions import ProtocolError
      +        exc = ProtocolError("Connection broken")
      +        assert is_stale_connection_error(exc) is True
      +
      +    def test_detects_library_internal_assertion_error(self):
      +        """A bare AssertionError raised from inside urllib3/botocore signals
      +        a corrupted connection-pool invariant and should trigger eviction."""
      +        from agent.bedrock_adapter import is_stale_connection_error
      +
      +        # Fabricate an AssertionError whose traceback's last frame belongs
      +        # to a module named "urllib3.connectionpool". We do this by exec'ing
      +        # a tiny `assert False` under a fake globals dict — the resulting
      +        # frame's ``f_globals["__name__"]`` is what the classifier inspects.
      +        fake_globals = {"__name__": "urllib3.connectionpool"}
      +        try:
      +            exec("def _boom():\n    assert False\n_boom()", fake_globals)
      +        except AssertionError as exc:
      +            assert is_stale_connection_error(exc) is True
      +        else:
      +            pytest.fail("AssertionError not raised")
      +
      +    def test_detects_botocore_internal_assertion_error(self):
      +        """Same as above but for a frame inside the botocore namespace."""
      +        from agent.bedrock_adapter import is_stale_connection_error
      +        fake_globals = {"__name__": "botocore.httpsession"}
      +        try:
      +            exec("def _boom():\n    assert False\n_boom()", fake_globals)
      +        except AssertionError as exc:
      +            assert is_stale_connection_error(exc) is True
      +        else:
      +            pytest.fail("AssertionError not raised")
      +
      +    def test_ignores_application_assertion_error(self):
      +        """AssertionError from application code (not urllib3/botocore) should
      +        NOT be classified as stale — those are real test/code bugs."""
      +        from agent.bedrock_adapter import is_stale_connection_error
      +        try:
      +            assert False, "test-only"  # noqa: B011
      +        except AssertionError as exc:
      +            assert is_stale_connection_error(exc) is False
      +
      +    def test_ignores_unrelated_exceptions(self):
      +        from agent.bedrock_adapter import is_stale_connection_error
      +        assert is_stale_connection_error(ValueError("bad input")) is False
      +        assert is_stale_connection_error(KeyError("missing")) is False
      +
      +
      +class TestCallConverseInvalidatesOnStaleError:
      +    """call_converse / call_converse_stream evict the cached client when the
      +    boto3 call raises a stale-connection error — so the next invocation
      +    reconnects instead of reusing the dead socket."""
      +
      +    def test_converse_evicts_client_on_stale_error(self):
      +        from agent.bedrock_adapter import (
      +            _bedrock_runtime_client_cache,
      +            call_converse,
      +            reset_client_cache,
      +        )
      +        from botocore.exceptions import ConnectionClosedError
      +
      +        reset_client_cache()
      +        dead_client = MagicMock()
      +        dead_client.converse.side_effect = ConnectionClosedError(
      +            endpoint_url="https://bedrock.example",
      +        )
      +        _bedrock_runtime_client_cache["us-east-1"] = dead_client
      +
      +        with pytest.raises(ConnectionClosedError):
      +            call_converse(
      +                region="us-east-1",
      +                model="anthropic.claude-3-sonnet-20240229-v1:0",
      +                messages=[{"role": "user", "content": "hi"}],
      +            )
      +
      +        assert "us-east-1" not in _bedrock_runtime_client_cache, (
      +            "stale client should have been evicted so the retry reconnects"
      +        )
      +
      +    def test_converse_stream_evicts_client_on_stale_error(self):
      +        from agent.bedrock_adapter import (
      +            _bedrock_runtime_client_cache,
      +            call_converse_stream,
      +            reset_client_cache,
      +        )
      +        from botocore.exceptions import ConnectionClosedError
      +
      +        reset_client_cache()
      +        dead_client = MagicMock()
      +        dead_client.converse_stream.side_effect = ConnectionClosedError(
      +            endpoint_url="https://bedrock.example",
      +        )
      +        _bedrock_runtime_client_cache["us-east-1"] = dead_client
      +
      +        with pytest.raises(ConnectionClosedError):
      +            call_converse_stream(
      +                region="us-east-1",
      +                model="anthropic.claude-3-sonnet-20240229-v1:0",
      +                messages=[{"role": "user", "content": "hi"}],
      +            )
      +
      +        assert "us-east-1" not in _bedrock_runtime_client_cache
      +
      +    def test_converse_does_not_evict_on_non_stale_error(self):
      +        """Non-stale errors (e.g. ValidationException) leave the client cache alone."""
      +        from agent.bedrock_adapter import (
      +            _bedrock_runtime_client_cache,
      +            call_converse,
      +            reset_client_cache,
      +        )
      +        from botocore.exceptions import ClientError
      +
      +        reset_client_cache()
      +        live_client = MagicMock()
      +        live_client.converse.side_effect = ClientError(
      +            error_response={"Error": {"Code": "ValidationException", "Message": "bad"}},
      +            operation_name="Converse",
      +        )
      +        _bedrock_runtime_client_cache["us-east-1"] = live_client
      +
      +        with pytest.raises(ClientError):
      +            call_converse(
      +                region="us-east-1",
      +                model="anthropic.claude-3-sonnet-20240229-v1:0",
      +                messages=[{"role": "user", "content": "hi"}],
      +            )
      +
      +        assert _bedrock_runtime_client_cache.get("us-east-1") is live_client, (
      +            "validation errors do not indicate a dead connection — keep the client"
      +        )
      +
      +    def test_converse_leaves_successful_client_in_cache(self):
      +        from agent.bedrock_adapter import (
      +            _bedrock_runtime_client_cache,
      +            call_converse,
      +            reset_client_cache,
      +        )
      +
      +        reset_client_cache()
      +        live_client = MagicMock()
      +        live_client.converse.return_value = {
      +            "output": {"message": {"role": "assistant", "content": [{"text": "hi"}]}},
      +            "stopReason": "end_turn",
      +            "usage": {"inputTokens": 1, "outputTokens": 1, "totalTokens": 2},
      +        }
      +        _bedrock_runtime_client_cache["us-east-1"] = live_client
      +
      +        call_converse(
      +            region="us-east-1",
      +            model="anthropic.claude-3-sonnet-20240229-v1:0",
      +            messages=[{"role": "user", "content": "hi"}],
      +        )
      +
      +        assert _bedrock_runtime_client_cache.get("us-east-1") is live_client
    • tests/agent/test_bedrock_integration.py
      @@ -376,17 +376,15 @@ def test_apac_anthropic_haiku_inference_profile_preserved(self):
                   "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 @@ def test_bedrock_inference_profile_survives_build_kwargs(self):
                   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,157 @@ def test_bedrock_model_mangled_without_preserve_dots(self):
                   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"
      +
      +
      +# ---------------------------------------------------------------------------
      +# auxiliary_client Bedrock resolution — fix for #13919
      +# ---------------------------------------------------------------------------
      +# Before the fix, resolve_provider_client("bedrock", ...) fell through to the
      +# "unhandled auth_type" warning and returned (None, None), breaking all
      +# auxiliary tasks (compression, memory, summarization) for Bedrock users.
      +
      +
      +class TestAuxiliaryClientBedrockResolution:
      +    """Verify resolve_provider_client handles Bedrock's aws_sdk auth type."""
      +
      +    def test_bedrock_returns_client_with_credentials(self, monkeypatch):
      +        """With valid AWS credentials, Bedrock should return a usable client."""
      +        monkeypatch.setenv("AWS_ACCESS_KEY_ID", "[redacted:aws-access-key]")
      +        monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
      +        monkeypatch.setenv("AWS_REGION", "us-west-2")
      +
      +        mock_anthropic_bedrock = MagicMock()
      +        with patch("agent.anthropic_adapter.build_anthropic_bedrock_client",
      +                   return_value=mock_anthropic_bedrock):
      +            from agent.auxiliary_client import resolve_provider_client, AnthropicAuxiliaryClient
      +            client, model = resolve_provider_client("bedrock", None)
      +
      +        assert client is not None, (
      +            "resolve_provider_client('bedrock') returned None — "
      +            "aws_sdk auth type is not handled"
      +        )
      +        assert isinstance(client, AnthropicAuxiliaryClient)
      +        assert model is not None
      +        assert client.api_key == "aws-sdk"
      +        assert "us-west-2" in client.base_url
      +
      +    def test_bedrock_returns_none_without_credentials(self, monkeypatch):
      +        """Without AWS credentials, Bedrock should return (None, None) gracefully."""
      +        with patch("agent.bedrock_adapter.has_aws_credentials", return_value=False):
      +            from agent.auxiliary_client import resolve_provider_client
      +            client, model = resolve_provider_client("bedrock", None)
      +
      +        assert client is None
      +        assert model is None
      +
      +    def test_bedrock_uses_configured_region(self, monkeypatch):
      +        """Bedrock client base_url should reflect AWS_REGION."""
      +        monkeypatch.setenv("AWS_ACCESS_KEY_ID", "[redacted:aws-access-key]")
      +        monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
      +        monkeypatch.setenv("AWS_REGION", "eu-central-1")
      +
      +        with patch("agent.anthropic_adapter.build_anthropic_bedrock_client",
      +                   return_value=MagicMock()):
      +            from agent.auxiliary_client import resolve_provider_client
      +            client, _ = resolve_provider_client("bedrock", None)
      +
      +        assert client is not None
      +        assert "eu-central-1" in client.base_url
      +
      +    def test_bedrock_respects_explicit_model(self, monkeypatch):
      +        """When caller passes an explicit model, it should be used."""
      +        monkeypatch.setenv("AWS_ACCESS_KEY_ID", "[redacted:aws-access-key]")
      +        monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
      +
      +        with patch("agent.anthropic_adapter.build_anthropic_bedrock_client",
      +                   return_value=MagicMock()):
      +            from agent.auxiliary_client import resolve_provider_client
      +            _, model = resolve_provider_client(
      +                "bedrock", "us.anthropic.claude-sonnet-4-5-20250929-v1:0"
      +            )
      +
      +        assert "claude-sonnet" in model
      +
      +    def test_bedrock_async_mode(self, monkeypatch):
      +        """Async mode should return an AsyncAnthropicAuxiliaryClient."""
      +        monkeypatch.setenv("AWS_ACCESS_KEY_ID", "[redacted:aws-access-key]")
      +        monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
      +
      +        with patch("agent.anthropic_adapter.build_anthropic_bedrock_client",
      +                   return_value=MagicMock()):
      +            from agent.auxiliary_client import resolve_provider_client, AsyncAnthropicAuxiliaryClient
      +            client, model = resolve_provider_client("bedrock", None, async_mode=True)
      +
      +        assert client is not None
      +        assert isinstance(client, AsyncAnthropicAuxiliaryClient)
      +
      +    def test_bedrock_default_model_is_haiku(self, monkeypatch):
      +        """Default auxiliary model for Bedrock should be Haiku (fast, cheap)."""
      +        monkeypatch.setenv("AWS_ACCESS_KEY_ID", "[redacted:aws-access-key]")
      +        monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
      +
      +        with patch("agent.anthropic_adapter.build_anthropic_bedrock_client",
      +                   return_value=MagicMock()):
      +            from agent.auxiliary_client import resolve_provider_client
      +            _, model = resolve_provider_client("bedrock", None)
      +
      +        assert "haiku" in model.lower()
    • tests/agent/test_model_metadata.py
      @@ -588,6 +588,57 @@ def test_config_context_length_none_is_ignored(self, mock_fetch):
               assert result == 200000
       
       
      +# =========================================================================
      +# Bedrock context resolution — must run BEFORE custom-endpoint probe
      +# =========================================================================
      +
      +class TestBedrockContextResolution:
      +    """Regression tests for Bedrock context-length resolution order.
      +
      +    Bug: because ``bedrock-runtime.<region>.amazonaws.com`` is not listed in
      +    ``_URL_TO_PROVIDER``, ``_is_known_provider_base_url`` returned False and
      +    the custom-endpoint probe at step 2 ran first — fetching ``/models`` from
      +    Bedrock (which it doesn't serve), returning the 128K default-fallback
      +    before execution ever reached the Bedrock branch.
      +
      +    Fix: promote the Bedrock branch ahead of the custom-endpoint probe.
      +    """
      +
      +    @patch("agent.model_metadata.fetch_endpoint_model_metadata")
      +    def test_bedrock_provider_returns_static_table_before_probe(self, mock_fetch):
      +        """provider='bedrock' resolves via static table, bypasses /models probe."""
      +        ctx = get_model_context_length(
      +            "anthropic.claude-opus-4-v1:0",
      +            provider="bedrock",
      +            base_url="https://bedrock-runtime.us-east-1.amazonaws.com",
      +        )
      +        # Must return the static Bedrock table value (200K for Claude),
      +        # NOT DEFAULT_FALLBACK_CONTEXT (128K).
      +        assert ctx == 200000
      +        mock_fetch.assert_not_called()
      +
      +    @patch("agent.model_metadata.fetch_endpoint_model_metadata")
      +    def test_bedrock_url_without_provider_hint(self, mock_fetch):
      +        """bedrock-runtime host infers Bedrock even when provider is omitted."""
      +        ctx = get_model_context_length(
      +            "anthropic.claude-sonnet-4-v1:0",
      +            base_url="https://bedrock-runtime.us-west-2.amazonaws.com",
      +        )
      +        assert ctx == 200000
      +        mock_fetch.assert_not_called()
      +
      +    @patch("agent.model_metadata.fetch_endpoint_model_metadata")
      +    def test_non_bedrock_url_still_probes(self, mock_fetch):
      +        """Non-Bedrock hosts still reach the custom-endpoint probe."""
      +        mock_fetch.return_value = {"some-model": {"context_length": 50000}}
      +        ctx = get_model_context_length(
      +            "some-model",
      +            base_url="https://api.example.com/v1",
      +        )
      +        assert ctx == 50000
      +        assert mock_fetch.called
      +
      +
       # =========================================================================
       # _strip_provider_prefix — Ollama model:tag vs provider:model
       # =========================================================================
  • #17454 fix(anthropic): narrow dot→hyphen normalization to Claude models only (#17171)

    merged to main

    files: agent/anthropic_adapter.py

    Show the diff
    • agent/anthropic_adapter.py
      @@ -1076,9 +1076,12 @@ def normalize_model_name(model: str, preserve_dots: bool = False) -> str:
               # 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(".", "-")
      +        # Only convert dots to hyphens for Anthropic/Claude models.
      +        # Non-Anthropic models (gpt-5.4, gemini-2.5, etc.) use dots
      +        # as part of their canonical names.  See issue #17171.
      +        _lower = model.lower()
      +        if _lower.startswith("claude-") or _lower.startswith("anthropic/"):
      +            model = model.replace(".", "-")
           return model
       
       
  • #19660 fix(agent): preserve dots in model names for Xiaomi MiMo provider

    merged to main

    files: run_agent.py

    Show the diff
    • run_agent.py
      @@ -8203,6 +8203,7 @@ def _anthropic_preserve_dots(self) -> bool:
               """True when using an anthropic-compatible endpoint that preserves dots in model names.
               Alibaba/DashScope keeps dots (e.g. qwen3.5-plus).
               MiniMax keeps dots (e.g. MiniMax-M2.7).
      +        Xiaomi MiMo keeps dots (e.g. mimo-v2.5, mimo-v2.5-pro).
               OpenCode Go/Zen keeps dots for non-Claude models (e.g. minimax-m2.5-free).
               ZAI/Zhipu keeps dots (e.g. glm-4.7, glm-5.1).
               AWS Bedrock uses dotted inference-profile IDs
      @@ -8216,6 +8217,7 @@ def _anthropic_preserve_dots(self) -> bool:
                   "alibaba", "minimax", "minimax-cn",
                   "opencode-go", "opencode-zen",
                   "zai", "bedrock",
      +            "xiaomi",
               }:
                   return True
               base = (getattr(self, "base_url", "") or "").lower()
      @@ -8225,6 +8227,7 @@ def _anthropic_preserve_dots(self) -> bool:
                   or "minimax" in base
                   or "opencode.ai/zen/" in base
                   or "bigmodel.cn" in base
      +            or "xiaomimimo.com" in base
                   # AWS Bedrock runtime endpoints — defense-in-depth when
                   # ``provider`` is unset but ``base_url`` still names Bedrock.
                   or "bedrock-runtime." in base

18 PRs closed without merging

From the close comments: done elsewhere 6 · merged via another change 5 · duplicate 3 · superseded by another PR 2 · stale or abandoned 1 · other 1

  • #7157 fix: preserve dots in model names for custom base URLs · merged via another change → #8794
  • #11480 fix(agent): preserve dotted ZAI models on custom anthropic proxies · other → 7141cda, #13061
  • #11992 fix(run_agent): preserve dotted Bedrock inference-profile model IDs (#11976) · merged via another change → #12793
  • #12297 fix(anthropic_adapter): preserve dots in Bedrock Claude inference-profile IDs · duplicate → #12336
  • #12336 fix: preserve dots in Bedrock inference-profile IDs (#12295) · merged via another change → 1cf1016, f2fba4f, #11976, #12295, #12577, #12578, #13112
Show 13 more
  • #12759 fix(anthropic): preserve dots in Bedrock inference profile IDs · stale or abandoned
  • #13068 fix(agent): preserve dots in custom provider model IDs (#13061) · done elsewhere → 7141cda, #17454, #13061
  • #13112 fix: preserve Bedrock inference profile ID namespace separators · done elsewhere → f2fba4f, #14664, #15184, #12727, #13112
  • #13227 fix(anthropic): preserve Bedrock inference profile IDs in normalize_model_name() · merged via another change → f2fba4f, #12295, #12759, #13112, #12578
  • #13818 fix(agent): preserve dots in model names for Bedrock Mantle endpoints · superseded by another PR → #13821
  • #13821 fix(bedrock): preserve dots in model names for Bedrock Mantle proxy · done elsewhere → f2fba4f, #13816
  • #13998 fix(run_agent): add model.preserve_dots config flag for Bedrock-style proxies · done elsewhere → f2fba4f, #12295, #13953
  • #14664 fix(anthropic): auto-detect Bedrock model IDs in normalize_model_name (#12295) · done elsewhere → f2fba4f
  • #15637 fix(model-normalize): add xiaomi to preserve-dots allowlist (#15619) · superseded by another PR → 7141cda, 9be3ab1, #17454, #15619
  • #15868 fix: add xiaomi provider to dots-preserve whitelist · duplicate → #15637, #15619
  • #16157 fix(agent): preserve dots in model names for Xiaomi MiMo provider · merged via another change → #19660
  • #17823 fix(custom_providers): allow opt-in preserve_model_dots for Anthropic-compatible local proxies (#17452) · done elsewhere → 7141cda
  • #105699 fix(model_normalize): only normalize dots-to-hyphens for Claude models on anthropic provider · duplicate → #22246
Closed issues · 13
  • Closed#11464[Bug]: custom anthropic proxy rewrites z-ai/glm-5.1 to z-ai/glm-5-1
  • Closed#11976[Bug]: Bedrock inference profile model ID dots mangled to hyphens → HTTP 400 "invalid model identifier"
  • Closed#12295Bedrock Claude inference-profile IDs get dot-collapsed to hyphens -> BadRequestError 400
  • Closed#12727[Bug]: Bedrock inference profile IDs broken by normalize_model_name()
  • Closed#13059custom provider的model id被normalize_model_name错误修改
  • Closed#13061[Bug] normalize_model_name rewrites custom provider model IDs, breaking models with dots (e.g. z-ai/glm-5.1 via zenmux)
  • Closed#13816Bedrock Mantle proxy: model name dots mangled to dashes (404)
  • Closed#13953[Bug]: Allow users to opt into preserve_dots for third-party Bedrock-style proxies
  • Closed#14575Custom provider model names with dots are incorrectly converted to hyphens
  • Closed#15619Xiaomi/MiMo models require preserve_dots=True to keep model name format
  • Closed#16156[Bug]: _anthropic_preserve_dots() whitelist breaks dot-versioned model names for unlisted providers (e.g. Xiaomi MiMo)
  • Closed#17452custom_providers with anthropic_messages mangles dotted model names → 502 from local proxies
  • Closed#19239Xiaomi TokenPlan Anthropic-compatible endpoint should preserve dots in MiMo model IDs
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.06: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#12793, #15184, #17454, #19660
Closed issues
13
Attempts
18 PRs closed without merging
Fix link checked
4 May 2026

For your agent

The same facts, typed.