{"repo":"NousResearch/hermes-agent","public_id":"3863","display_label":"Adding AWS Bedrock as a native inference provider","summary":"Multiple proposals and implementations for integrating AWS Bedrock as a first-class provider, using various approaches including the Converse API, Anthropic SDK, and direct provider support.","subsystem":"agent","counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":10549},"resolution":{"state":"partially_resolved","by":"pr-10549"},"freshness":{"state":"stale","last_verified_at":"2026-04-15T23:17:18+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.33599999999999997},"fix_edges":[{"issue_number":3863,"pr_number":10549,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":3863,"title":"[Feature]: Native AWS Bedrock provider support","url":null,"state":"closed"},{"kind":"issue","number":8694,"title":"[Feature]: Add AWS Bedrock as first-class provider","url":null,"state":"closed"},{"kind":"pr","number":4346,"title":"feat: add AWS Bedrock provider","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"0cb8c51fa","number":0},{"kind":"commit","value":"0cb8c51fa","number":0},{"kind":"pr","value":"#7920","number":7920},{"kind":"pr","value":"#7920","number":7920}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":7920,"title":"feat: native AWS Bedrock provider via Converse API","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":10549,"title":"feat: native AWS Bedrock provider via Converse API","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/bedrock_adapter.py","agent/error_classifier.py","agent/model_metadata.py","agent/usage_pricing.py","hermes_cli/auth.py","hermes_cli/auth_commands.py","hermes_cli/config.py","hermes_cli/doctor.py","hermes_cli/main.py","hermes_cli/models.py","hermes_cli/providers.py","hermes_cli/runtime_provider.py","pyproject.toml","run_agent.py","tests/agent/test_bedrock_adapter.py","tests/agent/test_bedrock_integration.py","website/docs/guides/aws-bedrock.md"],"fix_hunks":[{"file":"agent/anthropic_adapter.py","hunks":["@@ -298,6 +298,33 @@ def build_anthropic_client(api_key: str, base_url: str = None):\n     return _anthropic_sdk.Anthropic(**kwargs)\n \n \n+def build_anthropic_bedrock_client(region: str):\n+    \"\"\"Create an AnthropicBedrock client for Bedrock Claude models.\n+\n+    Uses the Anthropic SDK's native Bedrock adapter, which provides full\n+    Claude feature parity: prompt caching, thinking budgets, adaptive\n+    thinking, fast mode — features not available via the Converse API.\n+\n+    Auth uses the boto3 default credential chain (IAM roles, SSO, env vars).\n+    \"\"\"\n+    if _anthropic_sdk is None:\n+        raise ImportError(\n+            \"The 'anthropic' package is required for the Bedrock provider. \"\n+            \"Install it with: pip install 'anthropic>=0.39.0'\"\n+        )\n+    if not hasattr(_anthropic_sdk, \"AnthropicBedrock\"):\n+        raise ImportError(\n+            \"anthropic.AnthropicBedrock not available. \"\n+            \"Upgrade with: pip install 'anthropic>=0.39.0'\"\n+        )\n+    from httpx import Timeout\n+\n+    return _anthropic_sdk.AnthropicBedrock(\n+        aws_region=region,\n+        timeout=Timeout(timeout=900.0, connect=10.0),\n+    )\n+\n+\n def read_claude_code_credentials() -> Optional[Dict[str, Any]]:\n     \"\"\"Read refreshable Claude Code OAuth credentials from ~/.claude/.credentials.json.\n "]},{"file":"agent/bedrock_adapter.py","hunks":["@@ -0,0 +1,1098 @@\n+\"\"\"AWS Bedrock Converse API adapter for Hermes Agent.\n+\n+Provides native integration with Amazon Bedrock using the Converse API,\n+bypassing the OpenAI-compatible endpoint in favor of direct AWS SDK calls.\n+This enables full access to the Bedrock ecosystem:\n+\n+  - **Native Converse API**: Unified interface for all Bedrock models\n+    (Claude, Nova, Llama, Mistral, etc.) with streaming support.\n+  - **AWS credential chain**: IAM roles, SSO profiles, environment variables,\n+    instance metadata — zero API key management for AWS-native environments.\n+  - **Dynamic model discovery**: Auto-discovers available foundation models\n+    and cross-region inference profiles via the Bedrock control plane.\n+  - **Guardrails support**: Optional Bedrock Guardrails configuration for\n+    content filtering and safety policies.\n+  - **Inference profiles**: Supports cross-region inference profiles\n+    (us.anthropic.claude-*, global.anthropic.claude-*) for better capacity\n+    and automatic failover.\n+\n+Architecture follows the same pattern as ``anthropic_adapter.py``:\n+  - All Bedrock-specific logic is isolated in this module.\n+  - Messages/tools are converted between OpenAI format and Converse format.\n+  - Responses are normalized back to OpenAI-compatible objects for the agent loop.\n+\n+Reference: OpenClaw's ``extensions/amazon-bedrock/`` plugin, which implements\n+the same Converse API integration in TypeScript via ``@aws-sdk/client-bedrock``.\n+\n+Requires: ``boto3`` (optional dependency — only needed when using the Bedrock provider).\n+\"\"\"\n+\n+import json\n+import logging\n+import os\n+import re\n+from types import SimpleNamespace\n+from typing import Any, Dict, List, Optional, Tuple\n+\n+logger = logging.getLogger(__name__)\n+\n+# ---------------------------------------------------------------------------\n+# Lazy boto3 import — only loaded when the Bedrock provider is actually used.\n+# This keeps startup fast for users who don't use Bedrock.\n+# ---------------------------------------------------------------------------\n+\n+_bedrock_runtime_client_cache: Dict[str, Any] = {}\n+_bedrock_control_client_cache: Dict[str, Any] = {}\n+\n+\n+def _require_boto3():\n+    \"\"\"Import boto3, raising a clear error if not installed.\"\"\"\n+    try:\n+        import boto3\n+        return boto3\n+    except ImportError:\n+        raise ImportError(\n+            \"The 'boto3' package is required for the AWS Bedrock provider. \"\n+            \"Install it with: pip install boto3\\n\"\n+            \"Or install Hermes with Bedrock support: pip install -e '.[bedrock]'\"\n+        )\n+\n+\n+def _get_bedrock_runtime_client(region: str):\n+    \"\"\"Get or create a cached ``bedrock-runtime`` client for the given region.\n+\n+    Uses the default AWS credential chain (env vars → profile → instance role).\n+    \"\"\"\n+    if region not in _bedrock_runtime_client_cache:\n+        boto3 = _require_boto3()\n+        _bedrock_runtime_client_cache[region] = boto3.client(\n+            \"bedrock-runtime\", region_name=region,\n+        )\n+    return _bedrock_runtime_client_cache[region]\n+\n+\n+def _get_bedrock_control_client(region: str):\n+    \"\"\"Get or create a cached ``bedrock`` control-plane client for model discovery.\"\"\"\n+    if region not in _bedrock_control_client_cache:\n+        boto3 = _require_boto3()\n+        _bedrock_control_client_cache[region] = boto3.client(\n+            \"bedrock\", region_name=region,\n+        )\n+    return _bedrock_control_client_cache[region]\n+\n+\n+def reset_client_cache():\n+    \"\"\"Clear cached boto3 clients. Used in tests and profile switches.\"\"\"\n+    _bedrock_runtime_client_cache.clear()\n+    _bedrock_control_client_cache.clear()\n+\n+\n+# ---------------------------------------------------------------------------\n+# AWS credential detection\n+# ---------------------------------------------------------------------------\n+\n+# Priority order matches OpenClaw's resolveAwsSdkEnvVarName():\n+#   1. AWS_BEARER_TOKEN_BEDROCK (Bedrock-specific bearer token)\n+#   2. AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY (explicit IAM credentials)\n+#   3. AWS_PROFILE (named profile → SSO, assume-role, etc.)\n+#   4. Implicit: instance role, ECS task role, Lambda execution role\n+_AWS_CREDENTIAL_ENV_VARS = [\n+    \"AWS_BEARER_TOKEN_BEDROCK\",\n+    \"AWS_ACCESS_KEY_ID\",\n+    \"AWS_PROFILE\",\n+    # These are checked by boto3's default chain but we list them for\n+    # has_aws_credentials() detection:\n+    \"AWS_CONTAINER_CREDENTIALS_RELATIVE_URI\",\n+    \"AWS_WEB_IDENTITY_TOKEN_FILE\",\n+]\n+\n+\n+def resolve_aws_auth_env_var(env: Optional[Dict[str, str]] = None) -> Optional[str]:\n+    \"\"\"Return the name of the AWS auth source that is active, or None.\n+\n+    Checks environment variables first, then falls back to boto3's credential\n+    chain for implicit sources (EC2 IMDS, ECS task role, etc.).\n+\n+    This mirrors OpenClaw's ``resolveAwsSdkEnvVarName()`` — used to detect\n+    whether the user has any AWS credentials configured without actually\n+    attempting to authenticate.\n+    \"\"\"\n+    env = env if env is not None else os.environ\n+    # Bearer token takes highest priority\n+    if env.get(\"AWS_BEARER_TOKEN_BEDROCK\", \"\").strip():\n+        return \"AWS_BEARER_TOKEN_BEDROCK\"\n+    # Explicit access key pair\n+    if (env.get(\"AWS_ACCESS_KEY_ID\", \"\").strip()\n+            and env.get(\"AWS_SECRET_ACCESS_KEY\", \"\").strip()):\n+        return \"AWS_ACCESS_KEY_ID\"\n+    # Named profile (SSO, assume-role, etc.)\n+    if env.get(\"AWS_PROFILE\", \"\").strip():\n+        return \"AWS_PROFILE\"\n+    # Container credentials (ECS, CodeBuild)\n+    if env.get(\"AWS_CONTAINER_CREDENTIALS_RELATIVE_URI\", \"\").strip():\n+        return \"AWS_CONTAINER_CREDENTIALS_RELATIVE_URI\"\n+    # Web identity (EKS IRSA)\n+    if env.get(\"AWS_WEB_IDENTITY_TOKEN_FILE\", \"\").strip():\n+        return \"AWS_WEB_IDENTITY_TOKEN_FILE\"\n+    # No env vars — check if boto3 can resolve credentials via IMDS or other\n+    # implicit sources (EC2 instance role, ECS task role, Lambda, etc.)\n+    try:\n+        import botocore.session\n+        session = botocore.session.get_session()\n+        credentials = [redacted:secret-assignment]\n+        if credentials is not None:\n+            resolved = credentials.get_frozen_credentials()\n+            if resolved and resolved.access_key:\n+                return \"iam-role\"\n+    except Exception:\n+        pass\n+    return None\n+\n+\n+def has_aws_credentials(env: Optional[Dict[str, str]] = None) -> bool:\n+    \"\"\"Return True if any AWS credential source is detected.\n+\n+    Checks environment variables first (fast, no I/O), then falls back to\n+    boto3's credential chain which covers EC2 instance roles, ECS task roles,\n+    Lambda execution roles, and other IMDS-based sources that don't set\n+    environment variables.\n+\n+    This two-tier approach mirrors the pattern from OpenClaw PR #62673:\n+    cloud environments (EC2, ECS, Lambda) provide credentials via instance\n+    metadata, not environment variables. The env-var check is a fast path\n+    for local development; the boto3 fallback covers all cloud deployments.\n+    \"\"\"\n+    if resolve_aws_auth_env_var(env) is not None:\n+        return True\n+    # Fall back to boto3's credential resolver — this covers EC2 instance\n+    # metadata (IMDS), ECS container credentials, and other implicit sources\n+    # that don't set environment variables.\n+    try:\n+        import botocore.session\n+        session = botocore.session.get_session()\n+        credentials = [redacted:secret-assignment]\n+        if credentials is not None:\n+            resolved = credentials.get_frozen_credentials()\n+            if resolved and resolved.access_key:\n+                return True\n+    except Exception:\n+        pass\n+    return False\n+\n+\n+def resolve_bedrock_region(env: Optional[Dict[str, str]] = None) -> str:\n+    \"\"\"Resolve the AWS region for Bedrock API calls.\n+\n+    Priority: AWS_REGION → AWS_DEFAULT_REGION → us-east-1 (fallback).\n+    \"\"\"\n+    env = env if env is not None else os.environ\n+    return (\n+        env.get(\"AWS_REGION\", \"\").strip()\n+        or env.get(\"AWS_DEFAULT_REGION\", \"\").strip()\n+        or \"us-east-1\"\n+    )\n+\n+\n+# ---------------------------------------------------------------------------\n+# Tool-calling capability detection\n+# ---------------------------------------------------------------------------\n+# Some Bedrock models don't support tool/function calling. Sending toolConfig\n+# to these models causes ValidationException. We maintain a denylist of known\n+# non-tool-calling model patterns and strip tools for them.\n+#\n+# This is a conservative approach: unknown models are assumed to support tools.\n+# If a model fails with a tool-related ValidationException, add it here.\n+\n+_NON_TOOL_CALLING_PATTERNS = [\n+    \"deepseek.r1\",          # DeepSeek R1 — reasoning only, no tool support\n+    \"deepseek-r1\",          # Alternate ID format\n+    \"stability.\",           # Image generation models\n+    \"cohere.embed\",         # Embedding models\n+    \"amazon.titan-embed\",   # Embedding models\n+]\n+\n+\n+def _model_supports_tool_use(model_id: str) -> bool:\n+    \"\"\"Return True if the model is expected to support tool/function calling.\n+\n+    Models in the denylist are known to reject toolConfig in the Converse API.\n+    Unknown models default to True (assume tool support).\n+    \"\"\"\n+    model_lower = model_id.lower()\n+    return not any(pattern in model_lower for pattern in _NON_TOOL_CALLING_PATTERNS)\n+\n+\n+def is_anthropic_bedrock_model(model_id: str) -> bool:\n+    \"\"\"Return True if the model is an Anthropic Claude model on Bedrock.\n+\n+    These models should use the AnthropicBedrock SDK path for full feature\n+    parity (prompt caching, thinking budgets, adaptive thinking).\n+    Non-Claude models use the Converse API path.\n+\n+    Matches:\n+      - ``anthropic.claude-*`` (foundation model IDs)\n+      - ``us.anthropic.claude-*`` (US inference profiles)\n+      - ``global.anthropic.claude-*`` (global inference profiles)\n+      - ``eu.anthropic.claude-*`` (EU inference profiles)\n+    \"\"\"\n+    model_lower = model_id.lower()\n+    # Strip regional prefix if present\n+    for prefix in (\"us.\", \"global.\", \"eu.\", \"ap.\", \"jp.\"):\n+        if model_lower.startswith(prefix):\n+            model_lower = model_lower[len(prefix):]\n+            break\n+    return model_lower.startswith(\"anthropic.claude\")\n+\n+\n+# ---------------------------------------------------------------------------\n+# Message format conversion: OpenAI → Bedrock Converse\n+# ---------------------------------------------------------------------------\n+\n+def convert_tools_to_converse(tools: List[Dict]) -> List[Dict]:\n+    \"\"\"Convert OpenAI-format tool definitions to Bedrock Converse ``toolConfig``.\n+\n+    OpenAI format::\n+\n+        {\"type\": \"function\", \"function\": {\"name\": \"...\", \"description\": \"...\",\n+         \"parameters\": {\"type\": \"object\", \"properties\": {...}}}}\n+\n+    Converse format::\n+\n+        {\"toolSpec\": {\"name\": \"...\", \"description\": \"...\",\n+         \"inputSchema\": {\"json\": {\"type\": \"object\", \"properties\": {...}}}}}\n+    \"\"\"\n+    if not tools:\n+        return []\n+    result = []\n+    for t in tools:\n+        fn = t.get(\"function\", {})\n+        name = fn.get(\"name\", \"\")\n+        description = fn.get(\"description\", \"\")\n+        parameters = fn.get(\"parameters\", {\"type\": \"object\", \"properties\": {}})\n+        result.append({\n+            \"toolSpec\": {\n+                \"name\": name,\n+                \"description\": description,\n+                \"inputSchema\": {\"json\": parameters},\n+            }\n+        })\n+    return result\n+\n+\n+def _convert_content_to_converse(content) -> List[Dict]:\n+    \"\"\"Convert OpenAI message content (string or list) to Converse content blocks.\n+\n+    Handles:\n+      - Plain text strings → [{\"text\": \"...\"}]\n+      - Content arrays with text/image_url parts → mixed text/image blocks\n+\n+    Filters out empty text blocks — Bedrock's Converse API rejects messages\n+    where a text content block has an empty ``text`` field (ValidationException:\n+    \"text content blocks must be non-empty\"). Ref: issue #9486.\n+    \"\"\"\n+    if content is None:\n+        return [{\"text\": \" \"}]\n+    if isinstance(content, str):\n+        return [{\"text\": content}] if content.strip() else [{\"text\": \" \"}]\n+    if isinstance(content, list):\n+        blocks = []\n+        for part in content:\n+            if isinstance(part, str):\n+                blocks.append({\"text\": part})\n+                continue\n+            if not isinstance(part, dict):\n+                continue\n+            part_type = part.get(\"type\", \"\")\n+            if part_type == \"text\":\n+                text = part.get(\"text\", \"\")\n+                blocks.append({\"text\": text if text else \" \"})\n+            elif part_type == \"image_url\":\n+                image_url = part.get(\"image_url\", {})\n+                url = image_url.get(\"url\", \"\") if isinstance(image_url, dict) else \"\"\n+                if url.startswith(\"data:\"):\n+                    # data:image/jpeg;base64,/9j/4AAQ...\n+                    header, _, data = url.partition(\",\")\n+                    media_type = \"image/jpeg\"\n+                    if header.startswith(\"data:\"):\n+                        mime_part = header[5:].split(\";\")[0]\n+                        if mime_part:\n+                            media_type = mime_part\n+                    blocks.append({\n+                        \"image\": {\n+                            \"format\": media_type.split(\"/\")[-1] if \"/\" in media_type else \"jpeg\",\n+                            \"source\": {\"bytes\": data},\n+                        }\n+                    })\n+                else:\n+                    # Remote URL — Converse doesn't support URLs directly,\n+                    # include as text reference for the model.\n+                    blocks.append({\"text\": f\"[Image: {url}]\"})\n+        return blocks if blocks else [{\"text\": \" \"}]\n+    return [{\"text\": str(content)}]\n+\n+\n+def convert_messages_to_converse(\n+    messages: List[Dict],\n+) -> Tuple[Optional[List[Dict]], List[Dict]]:\n+    \"\"\"Convert OpenAI-format messages to Bedrock Converse format.\n+\n+    Returns ``(system_prompt, converse_messages)`` where:\n+      - ``system_prompt`` is a list of system content blocks (or None)\n+      - ``converse_messages`` is the conversation in Converse format\n+\n+    Handles:\n+      - System messages → extracted as system prompt\n+      - User messages → ``{\"role\": \"user\", \"content\": [...]}``\n+      - Assistant messages → ``{\"role\": \"assistant\", \"content\": [...]}``\n+      - Tool calls → ``{\"toolUse\": {\"toolUseId\": ..., \"name\": ..., \"input\": ...}}``\n+      - Tool results → ``{\"toolResult\": {\"toolUseId\": ..., \"content\": [...]}}``\n+\n+    Converse requires strict user/assistant alternation. Consecutive messages\n+    with the same role are merged into a single message.\n+    \"\"\"\n+    system_blocks: List[Dict] = []\n+    converse_msgs: List[Dict] = []\n+\n+    for msg in messages:\n+        role = msg.get(\"role\", \"\")\n+        content = msg.get(\"content\")\n+\n+        if role == \"system\":\n+            # System messages become the system prompt\n+            if isinstance(content, str) and content.strip():\n+                system_blocks.append({\"text\": content})\n+            elif isinstance(content, list):\n+                for part in content:\n+                    if isinstance(part, dict) and part.get(\"type\") == \"text\":\n+                        system_blocks.append({\"text\": part.get(\"text\", \"\")})\n+                    elif isinstance(part, str):\n+                        system_blocks.append({\"text\": part})\n+            continue\n+\n+        if role == \"tool\":\n+            # Tool result messages → merge into the preceding user turn\n+            tool_call_id = msg.get(\"tool_call_id\", \"\")\n+            result_content = content if isinstance(content, str) else json.dumps(content)\n+            tool_result_block = {\n+                \"toolResult\": {\n+                    \"toolUseId\": tool_call_id,\n+                    \"content\": [{\"text\": result_content}],\n+                }\n+            }\n+            # In Converse, tool results go in a \"user\" role message\n+            if converse_msgs and converse_msgs[-1][\"role\"] == \"user\":\n+                converse_msgs[-1][\"content\"].append(tool_result_block)\n+            else:\n+                converse_msgs.append({\n+                    \"role\": \"user\",\n+                    \"content\": [tool_result_block],\n+                })\n+            continue\n+\n+        if role == \"assistant\":\n+            content_blocks = []\n+            # Convert text content\n+            if isinstance(content, str) and content.strip():\n+                content_blocks.append({\"text\": content})\n+            elif isinstance(content, list):\n+                content_blocks.extend(_convert_content_to_converse(content))\n+\n+            # Convert tool calls\n+            tool_calls = msg.get(\"tool_calls\", [])\n+            for tc in (tool_calls or []):\n+                fn = tc.get(\"function\", {})\n+                args_str = fn.get(\"arguments\", \"{}\")\n+                try:\n+                    args_dict = json.loads(args_str) if isinstance(args_str, str) else args_str\n+                except (json.JSONDecodeError, TypeError):\n+                    args_dict = {}\n+                content_blocks.append({\n+                    \"toolUse\": {\n+                        \"toolUseId\": tc.get(\"id\", \"\"),\n+                        \"name\": fn.get(\"name\", \"\"),\n+                        \"input\": args_dict,\n+                    }\n+                })\n+\n+            if not content_blocks:\n+                content_blocks = [{\"text\": \" \"}]\n+\n+            # Merge with previous assistant message if needed (strict alternation)\n+            if converse_msgs and converse_msgs[-1][\"role\"] == \"assistant\":\n+                converse_msgs[-1][\"content\"].extend(content_blocks)\n+            else:\n+                converse_msgs.append({\n+                    \"role\": \"assistant\",\n+                    \"content\": content_blocks,\n+                })\n+            continue\n+\n+        if role == \"user\":\n+            content_blocks = _convert_content_to_converse(content)\n+            # Merge with previous user message if needed (strict alternation)\n+            if converse_msgs and converse_msgs[-1][\"role\"] == \"user\":\n+                converse_msgs[-1][\"content\"].extend(content_blocks)\n+            else:\n+                converse_msgs.append({\n+                    \"role\": \"user\",\n+                    \"content\": content_blocks,\n+                })\n+            continue\n+\n+    # Converse requires the first message to be from the user\n+    if converse_msgs and converse_msgs[0][\"role\"] != \"user\":\n+        converse_msgs.insert(0, {\"role\": \"user\", \"content\": [{\"text\": \" \"}]})\n+\n+    # Converse requires the last message to be from the user\n+    if converse_msgs and converse_msgs[-1][\"role\"] != \"user\":\n+        converse_msgs.append({\"role\": \"user\", \"content\": [{\"text\": \" \"}]})\n+\n+    return (system_blocks if system_blocks else None, converse_msgs)\n+\n+\n+# ---------------------------------------------------------------------------\n+# Response format conversion: Bedrock Converse → OpenAI\n+# ---------------------------------------------------------------------------\n+\n+def _converse_stop_reason_to_openai(stop_reason: str) -> str:\n+    \"\"\"Map Bedrock Converse stop reasons to OpenAI finish_reason values.\"\"\"\n+    mapping = {\n+        \"end_turn\": \"stop\",\n+        \"stop_sequence\": \"stop\",\n+        \"tool_use\": \"tool_calls\",\n+        \"max_tokens\": \"length\",\n+        \"content_filtered\": \"content_filter\",\n+        \"guardrail_intervened\": \"content_filter\",\n+    }\n+    return mapping.get(stop_reason, \"stop\")\n+\n+\n+def normalize_converse_response(response: Dict) -> SimpleNamespace:\n+    \"\"\"Convert a Bedrock Converse API response to an OpenAI-compatible object.\n+\n+    The agent loop in ``run_agent.py`` expects responses shaped like\n+    ``openai.ChatCompletion`` — this function bridges the gap.\n+\n+    Returns a SimpleNamespace with:\n+      - ``.choices[0].message.content`` — text response\n+      - ``.choices[0].message.tool_calls`` — tool call list (if any)\n+      - ``.choices[0].finish_reason`` — stop/tool_calls/length\n+      - ``.usage`` — token usage stats\n+    \"\"\"\n+    output = response.get(\"output\", {})\n+    message = output.get(\"message\", {})\n+    content_blocks = message.get(\"content\", [])\n+    stop_reason = response.get(\"stopReason\", \"end_turn\")\n+\n+    text_parts = []\n+    tool_calls = []\n+\n+    for block in content_blocks:\n+        if \"text\" in block:\n+            text_parts.append(block[\"text\"])\n+        elif \"toolUse\" in block:\n+            tu = block[\"toolUse\"]\n+            tool_calls.append(SimpleNamespace(\n+                id=tu.get(\"toolUseId\", \"\"),\n+                type=\"function\",\n+                function=SimpleNamespace(\n+                    name=tu.get(\"name\", \"\"),\n+                    arguments=json.dumps(tu.get(\"input\", {})),\n+                ),\n+            ))\n+\n+    # Build the message object\n+    msg = SimpleNamespace(\n+        role=\"assistant\",\n+        content=\"\\n\".join(text_parts) if text_parts else None,\n+        tool_calls=tool_calls if tool_calls else None,\n+    )\n+\n+    # Build usage stats\n+    usage_data = response.get(\"usage\", {})\n+    usage = SimpleNamespace(\n+        prompt_tokens=[redacted:secret-assignment]\"inputTokens\", 0),\n+        completion_tokens=[redacted:secret-assignment]\"outputTokens\", 0),\n+        total_tokens=(\n+            usage_data.get(\"inputTokens\", 0) + usage_data.get(\"outputTokens\", 0)\n+        ),\n+    )\n+\n+    finish_reason = _converse_stop_reason_to_openai(stop_reason)\n+    if tool_calls and finish_reason == \"stop\":\n+        finish_reason = \"tool_calls\"\n+\n+    choice = SimpleNamespace(\n+        index=0,\n+        message=msg,\n+        finish_reason=finish_reason,\n+    )\n+\n+    return SimpleNamespace(\n+        choices=[choice],\n+        usage=usage,\n+        model=response.get(\"modelId\", \"\"),\n+    )\n+\n+\n+# ---------------------------------------------------------------------------\n+# Streaming response conversion\n+# ---------------------------------------------------------------------------\n+\n+def normalize_converse_stream_events(event_stream) -> SimpleNamespace:\n+    \"\"\"Consume a Bedrock ConverseStream event stream and build an OpenAI-compatible response.\n+\n+    Processes the stream events in order:\n+      - ``messageStart`` — role info\n+      - ``contentBlockStart`` — new text or toolUse block\n+      - ``contentBlockDelta`` — incremental text or toolUse input\n+      - ``contentBlockStop`` — block complete\n+      - ``messageStop`` — stop reason\n+      - ``metadata`` — usage stats\n+\n+    Returns the same shape as ``normalize_converse_response()``.\n+    \"\"\"\n+    return stream_converse_with_callbacks(event_stream)\n+\n+\n+def stream_converse_with_callbacks(\n+    event_stream,\n+    on_text_delta=None,\n+    on_tool_start=None,\n+    on_reasoning_delta=None,\n+    on_interrupt_check=None,\n+) -> SimpleNamespace:\n+    \"\"\"Process a Bedrock ConverseStream event stream with real-time callbacks.\n+\n+    This is the core streaming function that powers both the CLI's live token\n+    display and the gateway's progressive message updates.\n+\n+    Args:\n+        event_stream: The boto3 ``converse_stream()`` response containing a\n+            ``stream`` key with an iterable of events.\n+        on_text_delta: Called with each text chunk as it arrives. Only fires\n+            when no tool_use blocks have been seen (same semantics as the\n+            Anthropic and chat_completions streaming paths).\n+        on_tool_start: Called with the tool name when a toolUse block begins.\n+            Lets the TUI show a spinner while tool arguments are generated.\n+        on_reasoning_delta: Called with reasoning/thinking text chunks.\n+            Bedrock surfaces thinking via ``reasoning`` content block deltas\n+            on supported models (Claude 4.6+).\n+        on_interrupt_check: Called on each event. Should return True if the\n+            agent has been interrupted and streaming should stop.\n+\n+    Returns:\n+        An OpenAI-compatible SimpleNamespace response, identical in shape to\n+        ``normalize_converse_response()``.\n+    \"\"\"\n+    text_parts: List[str] = []\n+    tool_calls: List[SimpleNamespace] = []\n+    current_tool: Optional[Dict] = None\n+    current_text_buffer: List[str] = []\n+    has_tool_use = False\n+    stop_reason = \"end_turn\"\n+    usage_data: Dict[str, int] = {}\n+\n+    for event in event_stream.get(\"stream\", []):\n+        # Check for interrupt\n+        if on_interrupt_check and on_interrupt_check():\n+            break\n+\n+        if \"contentBlockStart\" in event:\n+            start = event[\"contentBlockStart\"].get(\"start\", {})\n+            if \"toolUse\" in start:\n+                has_tool_use = True\n+                # Flush any accumulated text\n+                if current_text_buffer:\n+                    text_parts.append(\"\".join(current_text_buffer))\n+                    current_text_buffer = []\n+                current_tool = {\n+                    \"toolUseId\": start[\"toolUse\"].get(\"toolUseId\", \"\"),\n+                    \"name\": start[\"toolUse\"].get(\"name\", \"\"),\n+                    \"input_json\": \"\",\n+                }\n+                if on_tool_start:\n+                    on_tool_start(current_tool[\"name\"])\n+\n+        elif \"contentBlockDelta\" in event:\n+            delta = event[\"contentBlockDelta\"].get(\"delta\", {})\n+            if \"text\" in delta:\n+                text = delta[\"text\"]\n+                current_text_buffer.append(text)\n+                # Fire text delta callback only when no tool calls are present\n+                # (same semantics as Anthropic/chat_completions streaming)\n+                if on_text_delta and not has_tool_use:\n+                    on_text_delta(text)\n+            elif \"toolUse\" in delta:\n+                if current_tool is not None:\n+                    current_tool[\"input_json\"] += delta[\"toolUse\"].get(\"input\", \"\")\n+            elif \"reasoningContent\" in delta:\n+                # Claude 4.6+ on Bedrock surfaces thinking via reasoningContent\n+                reasoning = delta[\"reasoningContent\"]\n+                if isinstance(reasoning, dict):\n+                    thinking_text = reasoning.get(\"text\", \"\")\n+                    if thinking_text and on_reasoning_delta:\n+                        on_reasoning_delta(thinking_text)\n+\n+        elif \"contentBlockStop\" in event:\n+            if current_tool is not None:\n+                try:\n+                    input_dict = json.loads(current_tool[\"input_json\"]) if current_tool[\"input_json\"] else {}\n+                except (json.JSONDecodeError, TypeError):\n+                    input_dict = {}\n+                tool_calls.append(SimpleNamespace(\n+                    id=current_tool[\"toolUseId\"],\n+                    type=\"function\",\n+                    function=SimpleNamespace(\n+                        name=current_tool[\"name\"],\n+                        arguments=json.dumps(input_dict),\n+                    ),\n+                ))\n+                current_tool = None\n+            elif current_text_buffer:\n+                text_parts.append(\"\".join(current_text_buffer))\n+                current_text_buffer = []\n+\n+        elif \"messageStop\" in event:\n+            stop_reason = event[\"messageStop\"].get(\"stopReason\", \"end_turn\")\n+\n+        elif \"metadata\" in event:\n+            meta_usage = event[\"metadata\"].get(\"usage\", {})\n+            usage_data = {\n+                \"inputTokens\": meta_usage.get(\"inputTokens\", 0),\n+                \"outputTokens\": meta_usage.get(\"outputTokens\", 0),\n+            }\n+\n+    # Flush remaining text\n+    if current_text_buffer:\n+        text_parts.append(\"\".join(current_text_buffer))\n+\n+    msg = SimpleNamespace(\n+        role=\"assistant\",\n+        content=\"\\n\".join(text_parts) if text_parts else None,\n+        tool_calls=tool_calls if tool_calls else None,\n+    )\n+\n+    usage = SimpleNamespace(\n+        prompt_tokens=[redacted:secret-assignment]\"inputTokens\", 0),\n+        completion_tokens=[redacted:secret-assignment]\"outputTokens\", 0),\n+        total_tokens=(\n+            usage_data.get(\"inputTokens\", 0) + usage_data.get(\"outputTokens\", 0)\n+        ),\n+    )\n+\n+    finish_reason = _converse_stop_reason_to_openai(stop_reason)\n+    if tool_calls and finish_reason == \"stop\":\n+        finish_reason = \"tool_calls\"\n+\n+    choice = SimpleNamespace(\n+        index=0,\n+        message=msg,\n+        finish_reason=finish_reason,\n+    )\n+\n+    return SimpleNamespace(\n+        choices=[choice],\n+        usage=usage,\n+        model=\"\",\n+    )\n+\n+\n+# ---------------------------------------------------------------------------\n+# High-level API: call Bedrock Converse\n+# ---------------------------------------------------------------------------\n+\n+def build_converse_kwargs(\n+    model: str,\n+    messages: List[Dict],\n+    tools: Optional[List[Dict]] = None,\n+    max_tokens: int = 4096,\n+    temperature: Optional[float] = None,\n+    top_p: Optional[float] = None,\n+    stop_sequences: Optional[List[str]] = None,\n+    guardrail_config: Optional[Dict] = None,\n+) -> Dict[str, Any]:\n+    \"\"\"Build kwargs for ``bedrock-runtime.converse()`` or ``converse_stream()``.\n+\n+    Converts OpenAI-format inputs to Converse API parameters.\n+    \"\"\"\n+    system_prompt, converse_messages = convert_messages_to_converse(messages)\n+\n+    kwargs: Dict[str, Any] = {\n+        \"modelId\": model,\n+        \"messages\": converse_messages,\n+        \"inferenceConfig\": {\n+            \"maxTokens\": max_tokens,\n+        },\n+    }\n+\n+    if system_prompt:\n+        kwargs[\"system\"] = system_prompt\n+\n+    if temperature is not None:\n+        kwargs[\"inferenceConfig\"][\"temperature\"] = temperature\n+\n+    if top_p is not None:\n+        kwargs[\"inferenceConfig\"][\"topP\"] = top_p\n+\n+    if stop_sequences:\n+        kwargs[\"inferenceConfig\"][\"stopSequences\"] = stop_sequences\n+\n+    if tools:\n+        converse_tools = convert_tools_to_converse(tools)\n+        if converse_tools:\n+            # Some Bedrock models don't support tool/function calling (e.g.\n+            # DeepSeek R1, reasoning-only models).  Sending toolConfig to\n+            # these models causes a ValidationException → retry loop → failure.\n+            # Strip tools for known non-tool-calling models and warn the user.\n+            # Ref: PR #7920 feedback from @ptlally, pattern from PR #4346.\n+            if _model_supports_tool_use(model):\n+                kwargs[\"toolConfig\"] = {\"tools\": converse_tools}\n+            else:\n+                logger.warning(\n+                    \"Model %s does not support tool calling — tools stripped. \"\n+                    \"The agent will operate in text-only mode.\", model\n+                )\n+\n+    if guardrail_config:\n+        kwargs[\"guardrailConfig\"] = guardrail_config\n+\n+    return kwargs\n+\n+\n+def call_converse(\n+    region: str,\n+    model: str,\n+    messages: List[Dict],\n+    tools: Optional[List[Dict]] = None,\n+    max_tokens: int = 4096,\n+    temperature: Optional[float] = None,\n+    top_p: Optional[float] = None,\n+    stop_sequences: Optional[List[str]] = None,\n+    guardrail_config: Optional[Dict] = None,\n+) -> SimpleNamespace:\n+    \"\"\"Call Bedrock Converse API (non-streaming) and return an OpenAI-compatible response.\n+\n+    This is the primary entry point for the agent loop when using the Bedrock provider.\n+    \"\"\"\n+    client = _get_bedrock_runtime_client(region)\n+    kwargs = build_converse_kwargs(\n+        model=model,\n+        messages=messages,\n+        tools=tools,\n+        max_tokens=[redacted:secret-assignment]\n+        temperature=temperature,\n+        top_p=top_p,\n+        stop_sequences=stop_sequences,\n+        guardrail_config=guardrail_config,\n+    )\n+\n+    response = client.converse(**kwargs)\n+    return normalize_converse_response(response)\n+\n+\n+def call_converse_stream(\n+    region: str,\n+    model: str,\n+    messages: List[Dict],\n+    tools: Optional[List[Dict]] = None,\n+    max_tokens: int = 4096,\n+    temperature: Optional[float] = None,\n+    top_p: Optional[float] = None,\n+    stop_sequences: Optional[List[str]] = None,\n+    guardrail_config: Optional[Dict] = None,\n+) -> SimpleNamespace:\n+    \"\"\"Call Bedrock ConverseStream API and return an OpenAI-compatible response.\n+\n+    Consumes the full stream and returns the assembled response. For true\n+    streaming with delta callbacks, use ``iter_converse_stream()`` instead.\n+    \"\"\"\n+    client = _get_bedrock_runtime_client(region)\n+    kwargs = build_converse_kwargs(\n+        model=model,\n+        messages=messages,\n+        tools=tools,\n+        max_tokens=[redacted:secret-assignment]\n+        temperature=temperature,\n+        top_p=top_p,\n+        stop_sequences=stop_sequences,\n+        guardrail_config=guardrail_config,\n+    )\n+\n+    response = client.converse_stream(**kwargs)\n+    return normalize_converse_stream_events(response)\n+\n+\n+# ---------------------------------------------------------------------------\n+# Model discovery\n+# ---------------------------------------------------------------------------\n+\n+_discovery_cache: Dict[str, Any] = {}\n+_DISCOVERY_CACHE_TTL_SECONDS = 3600\n+\n+\n+def reset_discovery_cache():\n+    \"\"\"Clear the model discovery cache. Used in tests.\"\"\"\n+    _discovery_cache.clear()\n+\n+\n+def discover_bedrock_models(\n+    region: str,\n+    provider_filter: Optional[List[str]] = None,\n+) -> List[Dict[str, Any]]:\n+    \"\"\"Discover available Bedrock foundation models and inference profiles.\n+\n+    Returns a list of model info dicts with keys:\n+      - ``id``: Model ID (e.g. \"anthropic.claude-sonnet-4-6-20250514-v1:0\")\n+      - ``name``: Human-readable name\n+      - ``provider``: Model provider (e.g. \"Anthropic\", \"Amazon\", \"Meta\")\n+      - ``input_modalities``: List of input types (e.g. [\"TEXT\", \"IMAGE\"])\n+      - ``output_modalities``: List of output types\n+      - ``streaming``: Whether streaming is supported\n+\n+    Caches results for 1 hour per region to avoid repeated API calls.\n+\n+    Mirrors OpenClaw's ``discoverBedrockModels()`` in\n+    ``extensions/amazon-bedrock/discovery.ts``.\n+    \"\"\"\n+    import time\n+\n+    cache_key = f\"{region}:{','.join(sorted(provider_filter or []))}\"\n+    cached = _discovery_cache.get(cache_key)\n+    if cached and (time.time() - cached[\"timestamp\"]) < _DISCOVERY_CACHE_TTL_SECONDS:\n+        return cached[\"models\"]\n+\n+    try:\n+        client = _get_bedrock_control_client(region)\n+    except Exception as e:\n+        logger.warning(\"Failed to create Bedrock client for model discovery: %s\", e)\n+        return []\n+\n+    models = []\n+    seen_ids = set()\n+    filter_set = {f.lower() for f in (provider_filter or [])}\n+\n+    # 1. Discover foundation models\n+    try:\n+        response = client.list_foundation_models()\n+        for summary in response.get(\"modelSummaries\", []):\n+            model_id = (summary.get(\"modelId\") or \"\").strip()\n+            if not model_id:\n+                continue\n+\n+            # Apply provider filter\n+            if filter_set:\n+                provider_name = (summary.get(\"providerName\") or \"\").lower()\n+                model_prefix = model_id.split(\".\")[0].lower() if \".\" in model_id else \"\"\n+                if provider_name not in filter_set and model_prefix not in filter_set:\n+                    continue\n+\n+            # Only include active, streaming-capable, text-output models\n+            lifecycle = summary.get(\"modelLifecycle\", {})\n+            if lifecycle.get(\"status\", \"\").upper() != \"ACTIVE\":\n+                continue\n+            if not summary.get(\"responseStreamingSupported\", False):\n+                continue\n+            output_mods = summary.get(\"outputModalities\", [])\n+            if \"TEXT\" not in output_mods:\n+                continue\n+\n+            models.append({\n+                \"id\": model_id,\n+                \"name\": (summary.get(\"modelName\") or model_id).strip(),\n+                \"provider\": (summary.get(\"providerName\") or \"\").strip(),\n+                \"input_modalities\": summary.get(\"inputModalities\", []),\n+                \"output_modalities\": output_mods,\n+                \"streaming\": True,\n+            })\n+            seen_ids.add(model_id.lower())\n+    except Exception as e:\n+        logger.warning(\"Failed to list Bedrock foundation models: %s\", e)\n+\n+    # 2. Discover inference profiles (cross-region, better capacity)\n+    try:\n+        profiles = []\n+        next_token = None\n+        while True:\n+            kwargs = {}\n+            if next_token:\n+                kwargs[\"nextToken\"] = next_token\n+            response = client.list_inference_profiles(**kwargs)\n+            for profile in response.get(\"inferenceProfileSummaries\", []):\n+                profiles.append(profile)\n+            next_token = [redacted:secret-assignment]\"nextToken\")\n+            if not next_token:\n+                break\n+\n+        for profile in profiles:\n+            profile_id = (profile.get(\"inferenceProfileId\") or \"\").strip()\n+            if not profile_id:\n+                continue\n+            if profile.get(\"status\") != \"ACTIVE\":\n+                continue\n+            if profile_id.lower() in seen_ids:\n+                continue\n+\n+            # Apply provider filter to underlying models\n+            if filter_set:\n+                profile_models = profile.get(\"models\", [])\n+                matches = any(\n+                    _extract_provider_from_arn(m.get(\"modelArn\", \"\")).lower() in filter_set\n+                    for m in profile_models\n+                )\n+                if not matches:\n+                    continue\n+\n+            models.append({\n+                \"id\": profile_id,\n+                \"name\": (profile.get(\"inferenceProfileName\") or profile_id).strip(),\n+                \"provider\": \"inference-profile\",\n+                \"input_modalities\": [\"TEXT\"],\n+                \"output_modalities\": [\"TEXT\"],\n+                \"streaming\": True,\n+            })\n+            seen_ids.add(profile_id.lower())\n+    except Exception as e:\n+        logger.debug(\"Skipping inference profile discovery: %s\", e)\n+\n+    # Sort: global cross-region profiles first (recommended), then alphabetical\n+    models.sort(key=lambda m: (\n+        0 if m[\"id\"].startswith(\"global.\") else 1,\n+        m[\"name\"].lower(),\n+    ))\n+\n+    _discovery_cache[cache_key] = {\n+        \"timestamp\": time.time(),\n+        \"models\": models,\n+    }\n+    return models\n+\n+\n+def _extract_provider_from_arn(arn: str) -> str:\n+    \"\"\"Extract the model provider from a Bedrock model ARN.\n+\n+    Example: \"arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-v2\"\n+    → \"anthropic\"\n+    \"\"\"\n+    match = re.search(r\"foundation-model/([^.]+)\", arn)\n+    return match.group(1) if match else \"\"\n+\n+\n+def get_bedrock_model_ids(region: str) -> List[str]:\n+    \"\"\"Return a flat list of available Bedrock model IDs for the given region.\n+\n+    Convenience wrapper around ``discover_bedrock_models()`` for use in\n+    the model selection UI.\n+    \"\"\"\n+    models = discover_bedrock_models(region)\n+    return [m[\"id\"] for m in models]\n+\n+\n+# ---------------------------------------------------------------------------\n+# Error classification — Bedrock-specific exceptions\n+# ---------------------------------------------------------------------------\n+# Mirrors OpenClaw's classifyFailoverReason() and matchesContextOverflowError()\n+# in extensions/amazon-bedrock/register.sync.runtime.ts.\n+\n+# Patterns that indicate the input context exceeded the model's token limit.\n+# Used by run_agent.py to trigger context compression instead of retrying.\n+CONTEXT_OVERFLOW_PATTERNS = [\n+    re.compile(r\"ValidationException.*(?:input is too long|max input token|input token.*exceed)\", re.IGNORECASE),\n+    re.compile(r\"ValidationException.*(?:exceeds? the (?:maximum|max) (?:number of )?(?:input )?tokens)\", re.IGNORECASE),\n+    re.compile(r\"ModelStreamErrorException.*(?:Input is too long|too many input tokens)\", re.IGNORECASE),\n+]\n+\n+# Patterns for throttling / rate limit errors — should trigger backoff + retry.\n+THROTTLE_PATTERNS = [\n+    re.compile(r\"ThrottlingException\", re.IGNORECASE),\n+    re.compile(r\"Too many concurrent requests\", re.IGNORECASE),\n+    re.compile(r\"ServiceQuotaExceededException\", re.IGNORECASE),\n+]\n+\n+# Patterns for transient overload — model is temporarily unavailable.\n+OVERLOAD_PATTERNS = [\n+    re.compile(r\"ModelNotReadyException\", re.IGNORECASE),\n+    re.compile(r\"ModelTimeoutException\", re.IGNORECASE),\n+    re.compile(r\"InternalServerException\", re.IGNORECASE),\n+]\n+\n+\n+def is_context_overflow_error(error_message: str) -> bool:\n+    \"\"\"Return True if the error indicates the input context was too large.\n+\n+    When this returns True, the agent should compress context and retry\n+    rather than treating it as a fatal error.\n+    \"\"\"\n+    return any(p.search(error_message) for p in CONTEXT_OVERFLOW_PATTERNS)\n+\n+\n+def classify_bedrock_error(error_message: str) -> str:\n+    \"\"\"Classify a Bedrock error for retry/failover decisions.\n+\n+    Returns:\n+      - ``\"context_overflow\"`` — input too long, compress and retry\n+      - ``\"rate_limit\"`` — throttled, backoff and retry\n+      - ``\"overloaded\"`` — model temporarily unavailable, retry with delay\n+      - ``\"unknown\"`` — unclassified error\n+    \"\"\"\n+    if is_context_overflow_error(error_message):\n+        return \"context_overflow\"\n+    if any(p.search(error_message) for p in THROTTLE_PATTERNS):\n+        return \"rate_limit\"\n+    if any(p.search(error_message) for p in OVERLOAD_PATTERNS):\n+        return \"overloaded\"\n+    return \"unknown\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Bedrock model context lengths\n+# ---------------------------------------------------------------------------\n+# Static fallback table for models where the Bedrock API doesn't expose\n+# context window sizes.  Used by agent/model_metadata.py when dynamic\n+# detection is unavailable.\n+\n+BEDROCK_CONTEXT_LENGTHS: Dict[str, int] = {\n+    # Anthropic Claude models on Bedrock\n+    \"anthropic.claude-opus-4-6\":     200_000,\n+    \"anthropic.claude-sonnet-4-6\":   200_000,\n+    \"anthropic.claude-sonnet-4-5\":   200_000,\n+    \"anthropic.claude-haiku-4-5\":    200_000,\n+    \"anthropic.claude-opus-4\":       200_000,\n+    \"anthropic.claude-sonnet-4\":     200_000,\n+    \"anthropic.claude-3-5-sonnet\":   200_000,\n+    \"anthropic.claude-3-5-haiku\":    200_000,\n+    \"anthropic.claude-3-opus\":       200_000,\n+    \"anthropic.claude-3-sonnet\":     200_000,\n+    \"anthropic.claude-3-haiku\":      200_000,\n+    # Amazon Nova\n+    \"amazon.nova-pro\":               300_000,\n+    \"amazon.nova-lite\":              300_000,\n+    \"amazon.nova-micro\":             128_000,\n+    # Meta Llama\n+    \"meta.llama4-maverick\":          128_000,\n+    \"meta.llama4-scout\":             128_000,\n+    \"meta.llama3-3-70b-instruct\":    128_000,\n+    # Mistral\n+    \"mistral.mistral-large\":         128_000,\n+    # DeepSeek\n+    \"deepseek.v3\":                   128_000,\n+}\n+\n+# Default for unknown Bedrock models\n+BEDROCK_DEFAULT_CONTEXT_LENGTH = 128_000\n+\n+\n+def get_bedrock_context_length(model_id: str) -> int:\n+    \"\"\"Look up the context window size for a Bedrock model.\n+\n+    Uses substring matching so versioned IDs like\n+    ``anthropic.claude-sonnet-4-6-20250514-v1:0`` resolve correctly.\n+    \"\"\"\n+    model_lower = model_id.lower()\n+    best_key = \"\"\n+    best_val = BEDROCK_DEFAULT_CONTEXT_LENGTH\n+    for key, val in BEDROCK_CONTEXT_LENGTHS.items():\n+        if key in model_lower and len(key) > len(best_key):\n+            best_key = key\n+            best_val = val\n+    return best_val"]},{"file":"agent/error_classifier.py","hunks":["@@ -112,6 +112,10 @@ def is_auth(self) -> bool:\n     \"please retry after\",\n     \"resource_exhausted\",\n     \"rate increased too quickly\",  # Alibaba/DashScope throttling\n+    # AWS Bedrock throttling\n+    \"throttlingexception\",\n+    \"too many concurrent requests\",\n+    \"servicequotaexceededexception\",\n ]\n \n # Usage-limit patterns that need disambiguation (could be billing OR rate_limit)","@@ -171,6 +175,11 @@ def is_auth(self) -> bool:\n     # Chinese error messages (some providers return these)\n     \"超过最大长度\",\n     \"上下文长度\",\n+    # AWS Bedrock Converse API error patterns\n+    \"input is too long\",\n+    \"max input token\",\n+    \"input token\",\n+    \"exceeds the maximum number of input tokens\",\n ]\n \n # Model not found patterns"]},{"file":"agent/model_metadata.py","hunks":["@@ -1012,6 +1012,16 @@ 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 (base_url and \"bedrock-runtime\" in base_url):\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     # 5. Provider-aware lookups (before generic OpenRouter cache)\n     # These are provider-specific and take priority over the generic OR cache,\n     # since the same model can have different context limits per provider"]},{"file":"agent/usage_pricing.py","hunks":["@@ -284,6 +284,80 @@ class CostResult:\n         source_url=\"https://ai.google.dev/pricing\",\n         pricing_version=\"google-pricing-2026-03-16\",\n     ),\n+    # AWS Bedrock — pricing per the Bedrock pricing page.\n+    # Bedrock charges the same per-token rates as the model provider but\n+    # through AWS billing.  These are the on-demand prices (no commitment).\n+    # Source: https://aws.amazon.com/bedrock/pricing/\n+    (\n+        \"bedrock\",\n+        \"anthropic.claude-opus-4-6\",\n+    ): PricingEntry(\n+        input_cost_per_million=Decimal(\"15.00\"),\n+        output_cost_per_million=Decimal(\"75.00\"),\n+        source=\"official_docs_snapshot\",\n+        source_url=\"https://aws.amazon.com/bedrock/pricing/\",\n+        pricing_version=\"bedrock-pricing-2026-04\",\n+    ),\n+    (\n+        \"bedrock\",\n+        \"anthropic.claude-sonnet-4-6\",\n+    ): PricingEntry(\n+        input_cost_per_million=Decimal(\"3.00\"),\n+        output_cost_per_million=Decimal(\"15.00\"),\n+        source=\"official_docs_snapshot\",\n+        source_url=\"https://aws.amazon.com/bedrock/pricing/\",\n+        pricing_version=\"bedrock-pricing-2026-04\",\n+    ),\n+    (\n+        \"bedrock\",\n+        \"anthropic.claude-sonnet-4-5\",\n+    ): PricingEntry(\n+        input_cost_per_million=Decimal(\"3.00\"),\n+        output_cost_per_million=Decimal(\"15.00\"),\n+        source=\"official_docs_snapshot\",\n+        source_url=\"https://aws.amazon.com/bedrock/pricing/\",\n+        pricing_version=\"bedrock-pricing-2026-04\",\n+    ),\n+    (\n+        \"bedrock\",\n+        \"anthropic.claude-haiku-4-5\",\n+    ): PricingEntry(\n+        input_cost_per_million=Decimal(\"0.80\"),\n+        output_cost_per_million=Decimal(\"4.00\"),\n+        source=\"official_docs_snapshot\",\n+        source_url=\"https://aws.amazon.com/bedrock/pricing/\",\n+        pricing_version=\"bedrock-pricing-2026-04\",\n+    ),\n+    (\n+        \"bedrock\",\n+        \"amazon.nova-pro\",\n+    ): PricingEntry(\n+        input_cost_per_million=Decimal(\"0.80\"),\n+        output_cost_per_million=Decimal(\"3.20\"),\n+        source=\"official_docs_snapshot\",\n+        source_url=\"https://aws.amazon.com/bedrock/pricing/\",\n+        pricing_version=\"bedrock-pricing-2026-04\",\n+    ),\n+    (\n+        \"bedrock\",\n+        \"amazon.nova-lite\",\n+    ): PricingEntry(\n+        input_cost_per_million=Decimal(\"0.06\"),\n+        output_cost_per_million=Decimal(\"0.24\"),\n+        source=\"official_docs_snapshot\",\n+        source_url=\"https://aws.amazon.com/bedrock/pricing/\",\n+        pricing_version=\"bedrock-pricing-2026-04\",\n+    ),\n+    (\n+        \"bedrock\",\n+        \"amazon.nova-micro\",\n+    ): PricingEntry(\n+        input_cost_per_million=Decimal(\"0.035\"),\n+        output_cost_per_million=Decimal(\"0.14\"),\n+        source=\"official_docs_snapshot\",\n+        source_url=\"https://aws.amazon.com/bedrock/pricing/\",\n+        pricing_version=\"bedrock-pricing-2026-04\",\n+    ),\n }\n \n "]},{"file":"hermes_cli/auth.py","hunks":["@@ -274,6 +274,14 @@ class ProviderConfig:\n         api_key_env_vars=(\"XIAOMI_API_KEY\",),\n         base_url_env_var=\"XIAOMI_BASE_URL\",\n     ),\n+    \"bedrock\": ProviderConfig(\n+        id=\"bedrock\",\n+        name=\"AWS Bedrock\",\n+        auth_type=\"aws_sdk\",\n+        inference_base_url=\"https://bedrock-runtime.us-east-1.amazonaws.com\",\n+        api_key_env_vars=(),\n+        base_url_env_var=\"BEDROCK_BASE_URL\",\n+    ),\n }\n \n ","@@ -924,6 +932,7 @@ def resolve_provider(\n         \"qwen-portal\": \"qwen-oauth\", \"qwen-cli\": \"qwen-oauth\", \"qwen-oauth\": \"qwen-oauth\",\n         \"hf\": \"huggingface\", \"hugging-face\": \"huggingface\", \"huggingface-hub\": \"huggingface\",\n         \"mimo\": \"xiaomi\", \"xiaomi-mimo\": \"xiaomi\",\n+        \"aws\": \"bedrock\", \"aws-bedrock\": \"bedrock\", \"amazon-bedrock\": \"bedrock\", \"amazon\": \"bedrock\",\n         \"go\": \"opencode-go\", \"opencode-go-sub\": \"opencode-go\",\n         \"kilo\": \"kilocode\", \"kilo-code\": \"kilocode\", \"kilo-gateway\": \"kilocode\",\n         # Local server aliases — route through the generic custom provider","@@ -980,6 +989,15 @@ def resolve_provider(\n             if has_usable_secret(os.getenv(env_var, \"\")):\n                 return pid\n \n+    # AWS Bedrock — detect via boto3 credential chain (IAM roles, SSO, env vars).\n+    # This runs after API-key providers so explicit keys always win.\n+    try:\n+        from agent.bedrock_adapter import has_aws_credentials\n+        if has_aws_credentials():\n+            return \"bedrock\"\n+    except ImportError:\n+        pass  # boto3 not installed — skip Bedrock auto-detection\n+\n     raise AuthError(\n         \"No inference provider configured. Run 'hermes model' to choose a \"\n         \"provider and model, or set an API key (OPENROUTER_API_KEY, \"","@@ -2446,6 +2464,13 @@ def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]:\n     pconfig = PROVIDER_REGISTRY.get(target)\n     if pconfig and pconfig.auth_type == \"api_key\":\n         return get_api_key_provider_status(target)\n+    # AWS SDK providers (Bedrock) — check via boto3 credential chain\n+    if pconfig and pconfig.auth_type == \"aws_sdk\":\n+        try:\n+            from agent.bedrock_adapter import has_aws_credentials\n+            return {\"logged_in\": has_aws_credentials(), \"provider\": target}\n+        except ImportError:\n+            return {\"logged_in\": False, \"provider\": target, \"error\": \"boto3 not installed\"}\n     return {\"logged_in\": False}\n \n "]},{"file":"hermes_cli/auth_commands.py","hunks":["@@ -368,6 +368,27 @@ def _interactive_auth() -> None:\n     print(\"=\" * 50)\n \n     auth_list_command(SimpleNamespace(provider=None))\n+\n+    # Show AWS Bedrock credential status (not in the pool — uses boto3 chain)\n+    try:\n+        from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region\n+        if has_aws_credentials():\n+            auth_source = resolve_aws_auth_env_var() or \"unknown\"\n+            region = resolve_bedrock_region()\n+            print(f\"bedrock (AWS SDK credential chain):\")\n+            print(f\"  Auth: {auth_source}\")\n+            print(f\"  Region: {region}\")\n+            try:\n+                import boto3\n+                sts = boto3.client(\"sts\", region_name=region)\n+                identity = sts.get_caller_identity()\n+                arn = identity.get(\"Arn\", \"unknown\")\n+                print(f\"  Identity: {arn}\")\n+            except Exception:\n+                print(f\"  Identity: (could not resolve — boto3 STS call failed)\")\n+            print()\n+    except ImportError:\n+        pass  # boto3 or bedrock_adapter not available\n     print()\n \n     # Main menu"]},{"file":"hermes_cli/config.py","hunks":["@@ -419,6 +419,27 @@ def _ensure_hermes_home_managed(home: Path):\n         \"protect_last_n\": 20,         # minimum recent messages to keep uncompressed\n \n     },\n+\n+    # AWS Bedrock provider configuration.\n+    # Only used when model.provider is \"bedrock\".\n+    \"bedrock\": {\n+        \"region\": \"\",  # AWS region for Bedrock API calls (empty = AWS_REGION env var → us-east-1)\n+        \"discovery\": {\n+            \"enabled\": True,           # Auto-discover models via ListFoundationModels\n+            \"provider_filter\": [],     # Only show models from these providers (e.g. [\"anthropic\", \"amazon\"])\n+            \"refresh_interval\": 3600,  # Cache discovery results for this many seconds\n+        },\n+        \"guardrail\": {\n+            # Amazon Bedrock Guardrails — content filtering and safety policies.\n+            # Create a guardrail in the Bedrock console, then set the ID and version here.\n+            # See: https://docs.aws.amazon.com/bedrock/latest/userguide/guardrails.html\n+            \"guardrail_identifier\": \"\",  # e.g. \"abc123def456\"\n+            \"guardrail_version\": \"\",     # e.g. \"1\" or \"DRAFT\"\n+            \"stream_processing_mode\": \"async\",  # \"sync\" or \"async\"\n+            \"trace\": \"disabled\",         # \"enabled\", \"disabled\", or \"enabled_full\"\n+        },\n+    },\n+\n     \"smart_model_routing\": {\n         \"enabled\": False,\n         \"max_simple_chars\": 160,","@@ -974,6 +995,22 @@ def _ensure_hermes_home_managed(home: Path):\n         \"category\": \"provider\",\n         \"advanced\": True,\n     },\n+    \"AWS_REGION\": {\n+        \"description\": \"AWS region for Bedrock API calls (e.g. us-east-1, eu-central-1)\",\n+        \"prompt\": \"AWS Region\",\n+        \"url\": \"https://docs.aws.amazon.com/bedrock/latest/userguide/bedrock-regions.html\",\n+        \"password\": False,\n+        \"category\": \"provider\",\n+        \"advanced\": True,\n+    },\n+    \"AWS_PROFILE\": {\n+        \"description\": \"AWS named profile for Bedrock authentication (from ~/.aws/credentials)\",\n+        \"prompt\": \"AWS Profile\",\n+        \"url\": None,\n+        \"password\": False,\n+        \"category\": \"provider\",\n+        \"advanced\": True,\n+    },\n \n     # ── Tool API keys ──\n     \"EXA_API_KEY\": {"]},{"file":"hermes_cli/doctor.py","hunks":["@@ -859,6 +859,31 @@ def run_doctor(args):\n             except Exception as _e:\n                 print(f\"\\r  {color('⚠', Colors.YELLOW)} {_label} {color(f'({_e})', Colors.DIM)}           \")\n \n+    # -- AWS Bedrock --\n+    # Bedrock uses the AWS SDK credential chain, not API keys.\n+    try:\n+        from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region\n+        if has_aws_credentials():\n+            _auth_var = resolve_aws_auth_env_var()\n+            _region = resolve_bedrock_region()\n+            _label = \"AWS Bedrock\".ljust(20)\n+            print(f\"  Checking AWS Bedrock...\", end=\"\", flush=True)\n+            try:\n+                import boto3\n+                _br_client = boto3.client(\"bedrock\", region_name=_region)\n+                _br_resp = _br_client.list_foundation_models()\n+                _model_count = len(_br_resp.get(\"modelSummaries\", []))\n+                print(f\"\\r  {color('✓', Colors.GREEN)} {_label} {color(f'({_auth_var}, {_region}, {_model_count} models)', Colors.DIM)}           \")\n+            except ImportError:\n+                print(f\"\\r  {color('⚠', Colors.YELLOW)} {_label} {color('(boto3 not installed — pip install hermes-agent[bedrock])', Colors.DIM)}           \")\n+                issues.append(\"Install boto3 for Bedrock: pip install hermes-agent[bedrock]\")\n+            except Exception as _e:\n+                _err_name = type(_e).__name__\n+                print(f\"\\r  {color('⚠', Colors.YELLOW)} {_label} {color(f'({_err_name}: {_e})', Colors.DIM)}           \")\n+                issues.append(f\"AWS Bedrock: {_err_name} — check IAM permissions for bedrock:ListFoundationModels\")\n+    except ImportError:\n+        pass  # bedrock_adapter not available — skip silently\n+\n     # =========================================================================\n     # Check: Submodules\n     # ========================================================================="]},{"file":"hermes_cli/main.py","hunks":["@@ -1139,6 +1139,8 @@ def _named_custom_provider_map(cfg) -> dict[str, dict[str, str]]:\n         _model_flow_anthropic(config, current_model)\n     elif selected_provider == \"kimi-coding\":\n         _model_flow_kimi(config, current_model)\n+    elif selected_provider == \"bedrock\":\n+        _model_flow_bedrock(config, current_model)\n     elif selected_provider in (\"gemini\", \"deepseek\", \"xai\", \"zai\", \"kimi-coding-cn\", \"minimax\", \"minimax-cn\", \"kilocode\", \"opencode-zen\", \"opencode-go\", \"ai-gateway\", \"alibaba\", \"huggingface\", \"xiaomi\", \"arcee\"):\n         _model_flow_api_key_provider(config, selected_provider, current_model)\n ","@@ -2425,6 +2427,252 @@ def _model_flow_kimi(config, current_model=\"\"):\n         print(\"No change.\")\n \n \n+def _model_flow_bedrock_api_key(config, region, current_model=\"\"):\n+    \"\"\"Bedrock API Key mode — uses the OpenAI-compatible bedrock-mantle endpoint.\n+\n+    For developers who don't have an AWS account but received a Bedrock API Key\n+    from their AWS admin. Works like any OpenAI-compatible endpoint.\n+    \"\"\"\n+    from hermes_cli.auth import _prompt_model_selection, _save_model_choice, deactivate_provider\n+    from hermes_cli.config import load_config, save_config, get_env_value, save_env_value\n+    from hermes_cli.models import _PROVIDER_MODELS\n+\n+    mantle_base_url = f\"https://bedrock-mantle.{region}.api.aws/v1\"\n+\n+    # Prompt for API key\n+    existing_key = get_env_value(\"AWS_BEARER_TOKEN_BEDROCK\") or \"\"\n+    if existing_key:\n+        print(f\"  Bedrock API Key: {existing_key[:12]}... ✓\")\n+    else:\n+        print(f\"  Endpoint: {mantle_base_url}\")\n+        print()\n+        try:\n+            import getpass\n+            api_key = [redacted:secret-assignment]\"  Bedrock API Key: \").strip()\n+        except (KeyboardInterrupt, EOFError):\n+            print()\n+            return\n+        if not api_key:\n+            print(\"  Cancelled.\")\n+            return\n+        save_env_value(\"AWS_BEARER_TOKEN_BEDROCK\", api_key)\n+        existing_key = api_key\n+        print(\"  ✓ API key saved.\")\n+    print()\n+\n+    # Model selection — use static list (mantle doesn't need boto3 for discovery)\n+    model_list = _PROVIDER_MODELS.get(\"bedrock\", [])\n+    print(f\"  Showing {len(model_list)} curated models\")\n+\n+    if model_list:\n+        selected = _prompt_model_selection(model_list, current_model=current_model)\n+    else:\n+        try:\n+            selected = input(\"  Model ID: \").strip()\n+        except (KeyboardInterrupt, EOFError):\n+            selected = None\n+\n+    if selected:\n+        _save_model_choice(selected)\n+\n+        # Save as custom provider pointing to bedrock-mantle\n+        cfg = load_config()\n+        model = cfg.get(\"model\")\n+        if not isinstance(model, dict):\n+            model = {\"default\": model} if model else {}\n+            cfg[\"model\"] = model\n+        model[\"provider\"] = \"custom\"\n+        model[\"base_url\"] = mantle_base_url\n+        model.pop(\"api_mode\", None)  # chat_completions is the default\n+\n+        # Also save region in bedrock config for reference\n+        bedrock_cfg = cfg.get(\"bedrock\", {})\n+        if not isinstance(bedrock_cfg, dict):\n+            bedrock_cfg = {}\n+        bedrock_cfg[\"region\"] = region\n+        cfg[\"bedrock\"] = bedrock_cfg\n+\n+        # Save the API key env var name so hermes knows where to find it\n+        save_env_value(\"OPENAI_API_KEY\", existing_key)\n+        save_env_value(\"OPENAI_BASE_URL\", mantle_base_url)\n+\n+        save_config(cfg)\n+        deactivate_provider()\n+\n+        print(f\"  Default model set to: {selected} (via Bedrock API Key, {region})\")\n+        print(f\"  Endpoint: {mantle_base_url}\")\n+    else:\n+        print(\"  No change.\")\n+\n+\n+def _model_flow_bedrock(config, current_model=\"\"):\n+    \"\"\"AWS Bedrock provider: verify credentials, pick region, discover models.\n+\n+    Uses the native Converse API via boto3 — not the OpenAI-compatible endpoint.\n+    Auth is handled by the AWS SDK default credential chain (env vars, profile,\n+    instance role), so no API key prompt is needed.\n+    \"\"\"\n+    from hermes_cli.auth import _prompt_model_selection, _save_model_choice, deactivate_provider\n+    from hermes_cli.config import load_config, save_config\n+    from hermes_cli.models import _PROVIDER_MODELS\n+\n+    # 1. Check for AWS credentials\n+    try:\n+        from agent.bedrock_adapter import (\n+            has_aws_credentials,\n+            resolve_aws_auth_env_var,\n+            resolve_bedrock_region,\n+            discover_bedrock_models,\n+        )\n+    except ImportError:\n+        print(\"  ✗ boto3 is not installed. Install it with:\")\n+        print(\"    pip install boto3\")\n+        print()\n+        return\n+\n+    if not has_aws_credentials():\n+        print(\"  ⚠ No AWS credentials detected via environment variables.\")\n+        print(\"  Bedrock will use boto3's default credential chain (IMDS, SSO, etc.)\")\n+        print()\n+\n+    auth_var = resolve_aws_auth_env_var()\n+    if auth_var:\n+        print(f\"  AWS credentials: [redacted:secret-assignment] ✓\")\n+    else:\n+        print(\"  AWS credentials: boto3 default chain (instance role / SSO)\")\n+    print()\n+\n+    # 2. Region selection\n+    current_region = resolve_bedrock_region()\n+    try:\n+        region_input = input(f\"  AWS Region [{current_region}]: \").strip()\n+    except (KeyboardInterrupt, EOFError):\n+        print()\n+        return\n+    region = region_input or current_region\n+\n+    # 2b. Authentication mode\n+    print(\"  Choose authentication method:\")\n+    print()\n+    print(\"    1. IAM credential chain (recommended)\")\n+    print(\"       Works with EC2 instance roles, SSO, env vars, aws configure\")\n+    print(\"    2. Bedrock API Key\")\n+    print(\"       Enter your Bedrock API Key directly — also supports\")\n+    print(\"       team scenarios where an admin distributes keys\")\n+    print()\n+    try:\n+        auth_choice = input(\"  Choice [1]: \").strip()\n+    except (KeyboardInterrupt, EOFError):\n+        print()\n+        return\n+\n+    if auth_choice == \"2\":\n+        _model_flow_bedrock_api_key(config, region, current_model)\n+        return\n+\n+    # 3. Model discovery — try live API first, fall back to static list\n+    print(f\"  Discovering models in {region}...\")\n+    live_models = discover_bedrock_models(region)\n+\n+    if live_models:\n+        _EXCLUDE_PREFIXES = (\n+            \"stability.\", \"cohere.embed\", \"twelvelabs.\", \"us.stability.\",\n+            \"us.cohere.embed\", \"us.twelvelabs.\", \"global.cohere.embed\",\n+            \"global.twelvelabs.\",\n+        )\n+        _EXCLUDE_SUBSTRINGS = (\"safeguard\", \"voxtral\", \"palmyra-vision\")\n+        filtered = []\n+        for m in live_models:\n+            mid = m[\"id\"]\n+            if any(mid.startswith(p) for p in _EXCLUDE_PREFIXES):\n+                continue\n+            if any(s in mid.lower() for s in _EXCLUDE_SUBSTRINGS):\n+                continue\n+            filtered.append(m)\n+\n+        # Deduplicate: prefer inference profiles (us.*, global.*) over bare\n+        # foundation model IDs.\n+        profile_base_ids = set()\n+        for m in filtered:\n+            mid = m[\"id\"]\n+            if mid.startswith((\"us.\", \"global.\")):\n+                base = mid.split(\".\", 1)[1] if \".\" in mid[3:] else mid\n+                profile_base_ids.add(base)\n+\n+        deduped = []\n+        for m in filtered:\n+            mid = m[\"id\"]\n+            if not mid.startswith((\"us.\", \"global.\")) and mid in profile_base_ids:\n+                continue\n+            deduped.append(m)\n+\n+        _RECOMMENDED = [\n+            \"us.anthropic.claude-sonnet-4-6\",\n+            \"us.anthropic.claude-opus-4-6\",\n+            \"us.anthropic.claude-haiku-4-5\",\n+            \"us.amazon.nova-pro\",\n+            \"us.amazon.nova-lite\",\n+            \"us.amazon.nova-micro\",\n+            \"deepseek.v3\",\n+            \"us.meta.llama4-maverick\",\n+            \"us.meta.llama4-scout\",\n+        ]\n+\n+        def _sort_key(m):\n+            mid = m[\"id\"]\n+            for i, rec in enumerate(_RECOMMENDED):\n+                if mid.startswith(rec):\n+                    return (0, i, mid)\n+            if mid.startswith(\"global.\"):\n+                return (1, 0, mid)\n+            return (2, 0, mid)\n+\n+        deduped.sort(key=_sort_key)\n+        model_list = [m[\"id\"] for m in deduped]\n+        print(f\"  Found {len(model_list)} text model(s) (filtered from {len(live_models)} total)\")\n+    else:\n+        model_list = _PROVIDER_MODELS.get(\"bedrock\", [])\n+        if model_list:\n+            print(f\"  Using {len(model_list)} curated models (live discovery unavailable)\")\n+        else:\n+            print(\"  No models found. Check IAM permissions for bedrock:ListFoundationModels.\")\n+            return\n+\n+    # 4. Model selection\n+    if model_list:\n+        selected = _prompt_model_selection(model_list, current_model=current_model)\n+    else:\n+        try:\n+            selected = input(\"  Model ID: \").strip()\n+        except (KeyboardInterrupt, EOFError):\n+            selected = None\n+\n+    if selected:\n+        _save_model_choice(selected)\n+\n+        cfg = load_config()\n+        model = cfg.get(\"model\")\n+        if not isinstance(model, dict):\n+            model = {\"default\": model} if model else {}\n+            cfg[\"model\"] = model\n+        model[\"provider\"] = \"bedrock\"\n+        model[\"base_url\"] = f\"https://bedrock-runtime.{region}.amazonaws.com\"\n+        model.pop(\"api_mode\", None)  # bedrock_converse is auto-detected\n+\n+        bedrock_cfg = cfg.get(\"bedrock\", {})\n+        if not isinstance(bedrock_cfg, dict):\n+            bedrock_cfg = {}\n+        bedrock_cfg[\"region\"] = region\n+        cfg[\"bedrock\"] = bedrock_cfg\n+\n+        save_config(cfg)\n+        deactivate_provider()\n+\n+        print(f\"  Default model set to: {selected} (via AWS Bedrock, {region})\")\n+    else:\n+        print(\"  No change.\")\n+\n+\n def _model_flow_api_key_provider(config, provider_id, current_model=\"\"):\n     \"\"\"Generic flow for API-key providers (z.ai, MiniMax, OpenCode, etc.).\"\"\"\n     from hermes_cli.auth import ("]},{"file":"hermes_cli/models.py","hunks":["@@ -303,6 +303,22 @@ def _codex_curated_models() -> list[str]:\n         \"XiaomiMiMo/MiMo-V2-Flash\",\n         \"moonshotai/Kimi-K2-Thinking\",\n     ],\n+    # AWS Bedrock — static fallback list used when dynamic discovery is\n+    # unavailable (no boto3, no credentials, or API error).  The agent\n+    # prefers live discovery via ListFoundationModels + ListInferenceProfiles.\n+    # Use inference profile IDs (us.*) since most models require them.\n+    \"bedrock\": [\n+        \"us.anthropic.claude-sonnet-4-6\",\n+        \"us.anthropic.claude-opus-4-6-v1\",\n+        \"us.anthropic.claude-haiku-4-5-20251001-v1:0\",\n+        \"us.anthropic.claude-sonnet-4-5-20250929-v1:0\",\n+        \"us.amazon.nova-pro-v1:0\",\n+        \"us.amazon.nova-lite-v1:0\",\n+        \"us.amazon.nova-micro-v1:0\",\n+        \"deepseek.v3.2\",\n+        \"us.meta.llama4-maverick-17b-instruct-v1:0\",\n+        \"us.meta.llama4-scout-17b-instruct-v1:0\",\n+    ],\n }\n \n # ---------------------------------------------------------------------------","@@ -536,6 +552,7 @@ class ProviderEntry(NamedTuple):\n     ProviderEntry(\"opencode-zen\",   \"OpenCode Zen\",             \"OpenCode Zen (35+ curated models, pay-as-you-go)\"),\n     ProviderEntry(\"opencode-go\",    \"OpenCode Go\",              \"OpenCode Go (open models, $10/month subscription)\"),\n     ProviderEntry(\"ai-gateway\",     \"Vercel AI Gateway\",        \"Vercel AI Gateway (200+ models, pay-per-use)\"),\n+    ProviderEntry(\"bedrock\",        \"AWS Bedrock\",              \"AWS Bedrock (Claude, Nova, Llama, DeepSeek — IAM or API key)\"),\n ]\n \n # Derived dicts — used throughout the codebase","@@ -587,6 +604,10 @@ class ProviderEntry(NamedTuple):\n     \"huggingface-hub\": \"huggingface\",\n     \"mimo\": \"xiaomi\",\n     \"xiaomi-mimo\": \"xiaomi\",\n+    \"aws\": \"bedrock\",\n+    \"aws-bedrock\": \"bedrock\",\n+    \"amazon-bedrock\": \"bedrock\",\n+    \"amazon\": \"bedrock\",\n     \"grok\": \"xai\",\n     \"x-ai\": \"xai\",\n     \"x.ai\": \"xai\",","@@ -1957,6 +1978,42 @@ def validate_requested_model(\n \n     # api_models is None — couldn't reach API.  Accept and persist,\n     # but warn so typos don't silently break things.\n+\n+    # Bedrock: use our own discovery instead of HTTP /models endpoint.\n+    # Bedrock's bedrock-runtime URL doesn't support /models — it uses the\n+    # AWS SDK control plane (ListFoundationModels + ListInferenceProfiles).\n+    if normalized == \"bedrock\":\n+        try:\n+            from agent.bedrock_adapter import discover_bedrock_models, resolve_bedrock_region\n+            region = resolve_bedrock_region()\n+            discovered = discover_bedrock_models(region)\n+            discovered_ids = {m[\"id\"] for m in discovered}\n+            if requested in discovered_ids:\n+                return {\n+                    \"accepted\": True,\n+                    \"persist\": True,\n+                    \"recognized\": True,\n+                    \"message\": None,\n+                }\n+            # Not in discovered list — still accept (user may have custom\n+            # inference profiles or cross-account access), but warn.\n+            suggestions = get_close_matches(requested, list(discovered_ids), n=3, cutoff=0.4)\n+            suggestion_text = \"\"\n+            if suggestions:\n+                suggestion_text = \"\\n  Similar models: \" + \", \".join(f\"`{s}`\" for s in suggestions)\n+            return {\n+                \"accepted\": True,\n+                \"persist\": True,\n+                \"recognized\": False,\n+                \"message\": (\n+                    f\"Note: `{requested}` was not found in Bedrock model discovery for {region}. \"\n+                    f\"It may still work with custom inference profiles or cross-account access.\"\n+                    f\"{suggestion_text}\"\n+                ),\n+            }\n+        except Exception:\n+            pass  # Fall through to generic warning\n+\n     provider_label = _PROVIDER_LABELS.get(normalized, normalized)\n     return {\n         \"accepted\": True,"]},{"file":"hermes_cli/providers.py","hunks":["@@ -236,6 +236,12 @@ class ProviderDef:\n     \"mimo\": \"xiaomi\",\n     \"xiaomi-mimo\": \"xiaomi\",\n \n+    # bedrock\n+    \"aws\": \"bedrock\",\n+    \"aws-bedrock\": \"bedrock\",\n+    \"amazon-bedrock\": \"bedrock\",\n+    \"amazon\": \"bedrock\",\n+\n     # arcee\n     \"arcee-ai\": \"arcee\",\n     \"arceeai\": \"arcee\",","@@ -262,6 +268,7 @@ class ProviderDef:\n     \"copilot-acp\": \"GitHub Copilot ACP\",\n     \"xiaomi\": \"Xiaomi MiMo\",\n     \"local\": \"Local endpoint\",\n+    \"bedrock\": \"AWS Bedrock\",\n }\n \n ","@@ -271,6 +278,7 @@ class ProviderDef:\n     \"openai_chat\": \"chat_completions\",\n     \"anthropic_messages\": \"anthropic_messages\",\n     \"codex_responses\": \"codex_responses\",\n+    \"bedrock_converse\": \"bedrock_converse\",\n }\n \n ","@@ -388,6 +396,10 @@ def determine_api_mode(provider: str, base_url: str = \"\") -> str:\n     if pdef is not None:\n         return TRANSPORT_TO_API_MODE.get(pdef.transport, \"chat_completions\")\n \n+    # Direct provider checks for providers not in HERMES_OVERLAYS\n+    if provider == \"bedrock\":\n+        return \"bedrock_converse\"\n+\n     # URL-based heuristics for custom / unknown providers\n     if base_url:\n         url_lower = base_url.rstrip(\"/\").lower()","@@ -395,6 +407,8 @@ def determine_api_mode(provider: str, base_url: str = \"\") -> str:\n             return \"anthropic_messages\"\n         if \"api.openai.com\" in url_lower:\n             return \"codex_responses\"\n+        if \"bedrock-runtime\" in url_lower and \"amazonaws.com\" in url_lower:\n+            return \"bedrock_converse\"\n \n     return \"chat_completions\"\n "]},{"file":"hermes_cli/runtime_provider.py","hunks":["@@ -124,7 +124,7 @@ def _copilot_runtime_api_mode(model_cfg: Dict[str, Any], api_key: str) -> str:\n         return \"chat_completions\"\n \n \n-_VALID_API_MODES = {\"chat_completions\", \"codex_responses\", \"anthropic_messages\"}\n+_VALID_API_MODES = {\"chat_completions\", \"codex_responses\", \"anthropic_messages\", \"bedrock_converse\"}\n \n \n def _parse_api_mode(raw: Any) -> Optional[str]:","@@ -836,6 +836,77 @@ def resolve_runtime_provider(\n             \"requested_provider\": requested_provider,\n         }\n \n+    # AWS Bedrock (native Converse API via boto3)\n+    if provider == \"bedrock\":\n+        from agent.bedrock_adapter import (\n+            has_aws_credentials,\n+            resolve_aws_auth_env_var,\n+            resolve_bedrock_region,\n+            is_anthropic_bedrock_model,\n+        )\n+        # When the user explicitly selected bedrock (not auto-detected),\n+        # trust boto3's credential chain — it handles IMDS, ECS task roles,\n+        # Lambda execution roles, SSO, and other implicit sources that our\n+        # env-var check can't detect.\n+        is_explicit = requested_provider in (\"bedrock\", \"aws\", \"aws-bedrock\", \"amazon-bedrock\", \"amazon\")\n+        if not is_explicit and not has_aws_credentials():\n+            raise AuthError(\n+                \"No AWS credentials found for Bedrock. Configure one of:\\n\"\n+                \"  - AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY\\n\"\n+                \"  - AWS_PROFILE (for SSO / named profiles)\\n\"\n+                \"  - IAM instance role (EC2, ECS, Lambda)\\n\"\n+                \"Or run 'aws configure' to set up credentials.\",\n+                code=\"no_aws_credentials\",\n+            )\n+        # Read bedrock-specific config from config.yaml\n+        from hermes_cli.config import load_config as _load_bedrock_config\n+        _bedrock_cfg = _load_bedrock_config().get(\"bedrock\", {})\n+        # Region priority: config.yaml bedrock.region → env var → us-east-1\n+        region = (_bedrock_cfg.get(\"region\") or \"\").strip() or resolve_bedrock_region()\n+        auth_source = resolve_aws_auth_env_var() or \"aws-sdk-default-chain\"\n+        # Build guardrail config if configured\n+        _gr = _bedrock_cfg.get(\"guardrail\", {})\n+        guardrail_config = None\n+        if _gr.get(\"guardrail_identifier\") and _gr.get(\"guardrail_version\"):\n+            guardrail_config = {\n+                \"guardrailIdentifier\": _gr[\"guardrail_identifier\"],\n+                \"guardrailVersion\": _gr[\"guardrail_version\"],\n+            }\n+            if _gr.get(\"stream_processing_mode\"):\n+                guardrail_config[\"streamProcessingMode\"] = _gr[\"stream_processing_mode\"]\n+            if _gr.get(\"trace\"):\n+                guardrail_config[\"trace\"] = _gr[\"trace\"]\n+        # Dual-path routing: Claude models use AnthropicBedrock SDK for full\n+        # feature parity (prompt caching, thinking budgets, adaptive thinking).\n+        # Non-Claude models use the Converse API for multi-model support.\n+        _current_model = str(model_cfg.get(\"default\") or \"\").strip()\n+        if is_anthropic_bedrock_model(_current_model):\n+            # Claude on Bedrock → AnthropicBedrock SDK → anthropic_messages path\n+            runtime = {\n+                \"provider\": \"bedrock\",\n+                \"api_mode\": \"anthropic_messages\",\n+                \"base_url\": f\"https://bedrock-runtime.{region}.amazonaws.com\",\n+                \"api_key\": \"aws-sdk\",\n+                \"source\": auth_source,\n+                \"region\": region,\n+                \"bedrock_anthropic\": True,  # Signal to use AnthropicBedrock client\n+                \"requested_provider\": requested_provider,\n+            }\n+        else:\n+            # Non-Claude (Nova, DeepSeek, Llama, etc.) → Converse API\n+            runtime = {\n+                \"provider\": \"bedrock\",\n+                \"api_mode\": \"bedrock_converse\",\n+                \"base_url\": f\"https://bedrock-runtime.{region}.amazonaws.com\",\n+                \"api_key\": \"aws-sdk\",\n+                \"source\": auth_source,\n+                \"region\": region,\n+                \"requested_provider\": requested_provider,\n+            }\n+        if guardrail_config:\n+            runtime[\"guardrail_config\"] = guardrail_config\n+        return runtime\n+\n     # API-key providers (z.ai/GLM, Kimi, MiniMax, MiniMax-CN)\n     pconfig = PROVIDER_REGISTRY.get(provider)\n     if pconfig and pconfig.auth_type == \"api_key\":"]},{"file":"pyproject.toml","hunks":["@@ -63,6 +63,7 @@ homeassistant = [\"aiohttp>=3.9.0,<4\"]\n sms = [\"aiohttp>=3.9.0,<4\"]\n acp = [\"agent-client-protocol>=0.9.0,<1.0\"]\n mistral = [\"mistralai>=2.3.0,<3\"]\n+bedrock = [\"boto3>=1.35.0,<2\"]\n termux = [\n   # Tested Android / Termux path: keeps the core CLI feature-rich while\n   # avoiding extras that currently depend on non-Android wheels (notably","@@ -108,6 +109,7 @@ all = [\n   \"hermes-agent[dingtalk]\",\n   \"hermes-agent[feishu]\",\n   \"hermes-agent[mistral]\",\n+  \"hermes-agent[bedrock]\",\n   \"hermes-agent[web]\",\n ]\n "]},{"file":"run_agent.py","hunks":["@@ -676,7 +676,7 @@ def __init__(\n         self.provider = provider_name or \"\"\n         self.acp_command = acp_command or command\n         self.acp_args = list(acp_args or args or [])\n-        if api_mode in {\"chat_completions\", \"codex_responses\", \"anthropic_messages\"}:\n+        if api_mode in {\"chat_completions\", \"codex_responses\", \"anthropic_messages\", \"bedrock_converse\"}:\n             self.api_mode = api_mode\n         elif self.provider == \"openai-codex\":\n             self.api_mode = \"codex_responses\"","@@ -691,6 +691,9 @@ def __init__(\n             # use a URL convention ending in /anthropic. Auto-detect these so the\n             # Anthropic Messages API adapter is used instead of chat completions.\n             self.api_mode = \"anthropic_messages\"\n+        elif self.provider == \"bedrock\" or \"bedrock-runtime\" in self._base_url_lower:\n+            # AWS Bedrock — auto-detect from provider name or base URL.\n+            self.api_mode = \"bedrock_converse\"\n         else:\n             self.api_mode = \"chat_completions\"\n ","@@ -876,24 +879,70 @@ def __init__(\n \n         if self.api_mode == \"anthropic_messages\":\n             from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token\n-            # Only fall back to ANTHROPIC_TOKEN when the provider is actually Anthropic.\n-            # Other anthropic_messages providers (MiniMax, Alibaba, etc.) must use their own API key.\n-            # Falling back would send Anthropic credentials to third-party endpoints (Fixes #1739, #minimax-401).\n-            _is_native_anthropic = self.provider == \"anthropic\"\n-            effective_key = (api_key or resolve_anthropic_token() or \"\") if _is_native_anthropic else (api_key or \"\")\n-            self.api_key = [redacted:secret-assignment]\n-            self._anthropic_api_key = [redacted:secret-assignment]\n-            self._anthropic_base_url = base_url\n-            from agent.anthropic_adapter import _is_oauth_token as _is_oat\n-            self._is_anthropic_oauth = _is_oat(effective_key)\n-            self._anthropic_client = build_anthropic_client(effective_key, base_url)\n-            # No OpenAI client needed for Anthropic mode\n+            # Bedrock + Claude → use AnthropicBedrock SDK for full feature parity\n+            # (prompt caching, thinking budgets, adaptive thinking).\n+            _is_bedrock_anthropic = self.provider == \"bedrock\"\n+            if _is_bedrock_anthropic:\n+                from agent.anthropic_adapter import build_anthropic_bedrock_client\n+                import re as _re\n+                _region_match = _re.search(r\"bedrock-runtime\\.([a-z0-9-]+)\\.\", base_url or \"\")\n+                _br_region = _region_match.group(1) if _region_match else \"us-east-1\"\n+                self._bedrock_region = _br_region\n+                self._anthropic_client = build_anthropic_bedrock_client(_br_region)\n+                self._anthropic_api_key = \"[redacted:secret-assignment]\"\n+                self._anthropic_base_url = base_url\n+                self._is_anthropic_oauth = False\n+                self.api_key = \"[redacted:secret-assignment]\"\n+                self.client = None\n+                self._client_kwargs = {}\n+                if not self.quiet_mode:\n+                    print(f\"🤖 AI Agent initialized with model: {self.model} (AWS Bedrock + AnthropicBedrock SDK, {_br_region})\")\n+            else:\n+                # Only fall back to ANTHROPIC_TOKEN when the provider is actually Anthropic.\n+                # Other anthropic_messages providers (MiniMax, Alibaba, etc.) must use their own API key.\n+                # Falling back would send Anthropic credentials to third-party endpoints (Fixes #1739, #minimax-401).\n+                _is_native_anthropic = self.provider == \"anthropic\"\n+                effective_key = (api_key or resolve_anthropic_token() or \"\") if _is_native_anthropic else (api_key or \"\")\n+                self.api_key = [redacted:secret-assignment]\n+                self._anthropic_api_key = [redacted:secret-assignment]\n+                self._anthropic_base_url = base_url\n+                from agent.anthropic_adapter import _is_oauth_token as _is_oat\n+                self._is_anthropic_oauth = _is_oat(effective_key)\n+                self._anthropic_client = build_anthropic_client(effective_key, base_url)\n+                # No OpenAI client needed for Anthropic mode\n+                self.client = None\n+                self._client_kwargs = {}\n+                if not self.quiet_mode:\n+                    print(f\"🤖 AI Agent initialized with model: {self.model} (Anthropic native)\")\n+                    if effective_key and len(effective_key) > 12:\n+                        print(f\"🔑 Using token: [redacted:secret-assignment]\")\n+        elif self.api_mode == \"bedrock_converse\":\n+            # AWS Bedrock — uses boto3 directly, no OpenAI client needed.\n+            # Region is extracted from the base_url or defaults to us-east-1.\n+            import re as _re\n+            _region_match = _re.search(r\"bedrock-runtime\\.([a-z0-9-]+)\\.\", base_url or \"\")\n+            self._bedrock_region = _region_match.group(1) if _region_match else \"us-east-1\"\n+            # Guardrail config — read from config.yaml at init time.\n+            self._bedrock_guardrail_config = None\n+            try:\n+                from hermes_cli.config import load_config as _load_br_cfg\n+                _gr = _load_br_cfg().get(\"bedrock\", {}).get(\"guardrail\", {})\n+                if _gr.get(\"guardrail_identifier\") and _gr.get(\"guardrail_version\"):\n+                    self._bedrock_guardrail_config = {\n+                        \"guardrailIdentifier\": _gr[\"guardrail_identifier\"],\n+                        \"guardrailVersion\": _gr[\"guardrail_version\"],\n+                    }\n+                    if _gr.get(\"stream_processing_mode\"):\n+                        self._bedrock_guardrail_config[\"streamProcessingMode\"] = _gr[\"stream_processing_mode\"]\n+                    if _gr.get(\"trace\"):\n+                        self._bedrock_guardrail_config[\"trace\"] = _gr[\"trace\"]\n+            except Exception:\n+                pass\n             self.client = None\n             self._client_kwargs = {}\n             if not self.quiet_mode:\n-                print(f\"🤖 AI Agent initialized with model: {self.model} (Anthropic native)\")\n-                if effective_key and len(effective_key) > 12:\n-                    print(f\"🔑 Using token: [redacted:secret-assignment]\")\n+                _gr_label = \" + Guardrails\" if self._bedrock_guardrail_config else \"\"\n+                print(f\"🤖 AI Agent initialized with model: {self.model} (AWS Bedrock, {self._bedrock_region}{_gr_label})\")\n         else:\n             if api_key and base_url:\n                 # Explicit credentials from CLI/gateway — construct directly.","@@ -4859,6 +4908,17 @@ def _call():\n                     )\n                 elif self.api_mode == \"anthropic_messages\":\n                     result[\"response\"] = self._anthropic_messages_create(api_kwargs)\n+                elif self.api_mode == \"bedrock_converse\":\n+                    # Bedrock uses boto3 directly — no OpenAI client needed.\n+                    from agent.bedrock_adapter import (\n+                        _get_bedrock_runtime_client,\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+                    result[\"response\"] = normalize_converse_response(raw_response)\n                 else:\n                     request_client_holder[\"client\"] = self._create_request_openai_client(reason=\"chat_completion_request\")\n                     result[\"response\"] = request_client_holder[\"client\"].chat.completions.create(**api_kwargs)","@@ -5098,6 +5158,65 @@ def _interruptible_streaming_api_call(\n             finally:\n                 self._codex_on_first_delta = None\n \n+        # Bedrock Converse uses boto3's converse_stream() with real-time delta\n+        # callbacks — same UX as Anthropic and chat_completions streaming.\n+        if self.api_mode == \"bedrock_converse\":\n+            result = {\"response\": None, \"error\": None}\n+            first_delta_fired = {\"done\": False}\n+            deltas_were_sent = {\"yes\": False}\n+\n+            def _fire_first():\n+                if not first_delta_fired[\"done\"] and on_first_delta:\n+                    first_delta_fired[\"done\"] = True\n+                    try:\n+                        on_first_delta()\n+                    except Exception:\n+                        pass\n+\n+            def _bedrock_call():\n+                try:\n+                    from agent.bedrock_adapter import (\n+                        _get_bedrock_runtime_client,\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+\n+                    def _on_text(text):\n+                        _fire_first()\n+                        self._fire_stream_delta(text)\n+                        deltas_were_sent[\"yes\"] = True\n+\n+                    def _on_tool(name):\n+                        _fire_first()\n+                        self._fire_tool_gen_started(name)\n+\n+                    def _on_reasoning(text):\n+                        _fire_first()\n+                        self._fire_reasoning_delta(text)\n+\n+                    result[\"response\"] = stream_converse_with_callbacks(\n+                        raw_response,\n+                        on_text_delta=_on_text if self._has_stream_consumers() else None,\n+                        on_tool_start=_on_tool,\n+                        on_reasoning_delta=_on_reasoning if self.reasoning_callback or self.stream_delta_callback else None,\n+                        on_interrupt_check=lambda: self._interrupt_requested,\n+                    )\n+                except Exception as e:\n+                    result[\"error\"] = e\n+\n+            t = threading.Thread(target=_bedrock_call, daemon=True)\n+            t.start()\n+            while t.is_alive():\n+                t.join(timeout=0.3)\n+                if self._interrupt_requested:\n+                    raise InterruptedError(\"Agent interrupted during Bedrock API call\")\n+            if result[\"error\"] is not None:\n+                raise result[\"error\"]\n+            return result[\"response\"]\n+\n         result = {\"response\": None, \"error\": None}\n         request_client_holder = {\"client\": None}\n         first_delta_fired = {\"done\": False}","@@ -5724,6 +5843,8 @@ def _try_activate_fallback(self) -> bool:\n                 # GPT-5.x models need Responses API on every provider\n                 # (OpenRouter, Copilot, direct OpenAI, etc.)\n                 fb_api_mode = \"codex_responses\"\n+            elif fb_provider == \"bedrock\" or \"bedrock-runtime\" in fb_base_url.lower():\n+                fb_api_mode = \"bedrock_converse\"\n \n             old_model = self.model\n             self.model = fb_model","@@ -6203,6 +6324,25 @@ def _build_api_kwargs(self, api_messages: list) -> dict:\n                 fast_mode=(self.request_overrides or {}).get(\"speed\") == \"fast\",\n             )\n \n+        # AWS Bedrock native Converse API — bypasses the OpenAI client entirely.\n+        # The adapter handles message/tool conversion and boto3 calls directly.\n+        if self.api_mode == \"bedrock_converse\":\n+            from agent.bedrock_adapter import build_converse_kwargs\n+            region = getattr(self, \"_bedrock_region\", None) or \"us-east-1\"\n+            guardrail = getattr(self, \"_bedrock_guardrail_config\", None)\n+            return {\n+                \"__bedrock_converse__\": True,\n+                \"__bedrock_region__\": region,\n+                **build_converse_kwargs(\n+                    model=self.model,\n+                    messages=api_messages,\n+                    tools=self.tools,\n+                    max_tokens=[redacted:secret-assignment] or 4096,\n+                    temperature=None,  # Let the model use its default\n+                    guardrail_config=guardrail,\n+                ),\n+            }\n+\n         if self.api_mode == \"codex_responses\":\n             instructions = \"\"\n             payload_messages = api_messages","@@ -8780,7 +8920,7 @@ def _stop_spinner():\n                         # targeted error instead of wasting 3 API calls.\n                         _trunc_content = None\n                         _trunc_has_tool_calls = False\n-                        if self.api_mode == \"chat_completions\":\n+                        if self.api_mode in (\"chat_completions\", \"bedrock_converse\"):\n                             _trunc_msg = response.choices[0].message if (hasattr(response, \"choices\") and response.choices) else None\n                             _trunc_content = getattr(_trunc_msg, \"content\", None) if _trunc_msg else None\n                             _trunc_has_tool_calls = bool(getattr(_trunc_msg, \"tool_calls\", None)) if _trunc_msg else False","@@ -8849,7 +8989,7 @@ def _stop_spinner():\n                                 \"error\": _exhaust_error,\n                             }\n \n-                        if self.api_mode == \"chat_completions\":\n+                        if self.api_mode in (\"chat_completions\", \"bedrock_converse\"):\n                             assistant_message = response.choices[0].message\n                             if not assistant_message.tool_calls:\n                                 length_continue_retries += 1","@@ -8889,7 +9029,7 @@ def _stop_spinner():\n                                     \"error\": \"Response remained truncated after 3 continuation attempts\",\n                                 }\n \n-                        if self.api_mode == \"chat_completions\":\n+                        if self.api_mode in (\"chat_completions\", \"bedrock_converse\"):\n                             assistant_message = response.choices[0].message\n                             if assistant_message.tool_calls:\n                                 if truncated_tool_call_retries < 1:"]},{"file":"tests/agent/test_bedrock_adapter.py","hunks":["@@ -0,0 +1,1232 @@\n+\"\"\"Tests for the AWS Bedrock Converse API adapter.\n+\n+Covers:\n+  - AWS credential detection and region resolution\n+  - Message format conversion (OpenAI → Converse and back)\n+  - Tool definition conversion\n+  - Response normalization (non-streaming and streaming)\n+  - Model discovery with caching\n+  - Edge cases: empty messages, consecutive roles, image content\n+\"\"\"\n+\n+import json\n+import os\n+import time\n+from types import SimpleNamespace\n+from unittest.mock import MagicMock, patch, PropertyMock\n+\n+import pytest\n+\n+\n+# ---------------------------------------------------------------------------\n+# AWS credential detection\n+# ---------------------------------------------------------------------------\n+\n+class TestResolveAwsAuthEnvVar:\n+    \"\"\"Test AWS credential environment variable detection.\n+\n+    Mirrors OpenClaw's resolveAwsSdkEnvVarName() priority order.\n+    \"\"\"\n+\n+    def test_prefers_bearer_token_over_access_keys_and_profile(self):\n+        from agent.bedrock_adapter import resolve_aws_auth_env_var\n+        env = {\n+            \"AWS_BEARER_TOKEN_BEDROCK\": \"bearer-token\",\n+            \"AWS_ACCESS_KEY_ID\": \"AKIA...\",\n+            \"AWS_SECRET_ACCESS_KEY\": \"secret\",\n+            \"AWS_PROFILE\": \"default\",\n+        }\n+        assert resolve_aws_auth_env_var(env) == \"AWS_BEARER_TOKEN_BEDROCK\"\n+\n+    def test_uses_access_keys_when_bearer_token_missing(self):\n+        from agent.bedrock_adapter import resolve_aws_auth_env_var\n+        env = {\n+            \"AWS_ACCESS_KEY_ID\": \"AKIA...\",\n+            \"AWS_SECRET_ACCESS_KEY\": \"secret\",\n+            \"AWS_PROFILE\": \"default\",\n+        }\n+        assert resolve_aws_auth_env_var(env) == \"AWS_ACCESS_KEY_ID\"\n+\n+    def test_requires_both_access_key_and_secret(self):\n+        from agent.bedrock_adapter import resolve_aws_auth_env_var\n+        # Only access key, no secret → should not match\n+        env = {\"AWS_ACCESS_KEY_ID\": \"AKIA...\"}\n+        assert resolve_aws_auth_env_var(env) != \"AWS_ACCESS_KEY_ID\"\n+\n+    def test_uses_profile_when_no_keys(self):\n+        from agent.bedrock_adapter import resolve_aws_auth_env_var\n+        env = {\"AWS_PROFILE\": \"production\"}\n+        assert resolve_aws_auth_env_var(env) == \"AWS_PROFILE\"\n+\n+    def test_uses_container_credentials(self):\n+        from agent.bedrock_adapter import resolve_aws_auth_env_var\n+        env = {\"AWS_CONTAINER_CREDENTIALS_RELATIVE_URI\": \"/v2/credentials/...\"}\n+        assert resolve_aws_auth_env_var(env) == \"AWS_CONTAINER_CREDENTIALS_RELATIVE_URI\"\n+\n+    def test_uses_web_identity(self):\n+        from agent.bedrock_adapter import resolve_aws_auth_env_var\n+        env = {\"AWS_WEB_IDENTITY_TOKEN_FILE\": \"/var/run/secrets/token\"}\n+        assert resolve_aws_auth_env_var(env) == \"AWS_WEB_IDENTITY_TOKEN_FILE\"\n+\n+    def test_returns_none_when_no_aws_auth(self):\n+        from agent.bedrock_adapter import resolve_aws_auth_env_var\n+        # Mock botocore to return no credentials (covers EC2 IMDS fallback)\n+        mock_session = MagicMock()\n+        mock_session.get_credentials.return_value = None\n+        with patch.dict(\"sys.modules\", {\"botocore\": MagicMock(), \"botocore.session\": MagicMock()}):\n+            import botocore.session as _bs\n+            _bs.get_session = MagicMock(return_value=mock_session)\n+            assert resolve_aws_auth_env_var({}) is None\n+\n+    def test_ignores_whitespace_only_values(self):\n+        from agent.bedrock_adapter import resolve_aws_auth_env_var\n+        env = {\"AWS_PROFILE\": \"  \", \"AWS_ACCESS_KEY_ID\": \" \"}\n+        mock_session = MagicMock()\n+        mock_session.get_credentials.return_value = None\n+        with patch.dict(\"sys.modules\", {\"botocore\": MagicMock(), \"botocore.session\": MagicMock()}):\n+            import botocore.session as _bs\n+            _bs.get_session = MagicMock(return_value=mock_session)\n+            assert resolve_aws_auth_env_var(env) is None\n+\n+\n+class TestHasAwsCredentials:\n+    def test_true_with_profile(self):\n+        from agent.bedrock_adapter import has_aws_credentials\n+        assert has_aws_credentials({\"AWS_PROFILE\": \"default\"}) is True\n+\n+    def test_false_with_empty_env(self):\n+        from agent.bedrock_adapter import has_aws_credentials\n+        mock_session = MagicMock()\n+        mock_session.get_credentials.return_value = None\n+        with patch.dict(\"sys.modules\", {\"botocore\": MagicMock(), \"botocore.session\": MagicMock()}):\n+            import botocore.session as _bs\n+            _bs.get_session = MagicMock(return_value=mock_session)\n+            assert has_aws_credentials({}) is False\n+\n+\n+class TestResolveBedrocRegion:\n+    def test_prefers_aws_region(self):\n+        from agent.bedrock_adapter import resolve_bedrock_region\n+        env = {\"AWS_REGION\": \"eu-west-1\", \"AWS_DEFAULT_REGION\": \"us-west-2\"}\n+        assert resolve_bedrock_region(env) == \"eu-west-1\"\n+\n+    def test_falls_back_to_default_region(self):\n+        from agent.bedrock_adapter import resolve_bedrock_region\n+        env = {\"AWS_DEFAULT_REGION\": \"ap-northeast-1\"}\n+        assert resolve_bedrock_region(env) == \"ap-northeast-1\"\n+\n+    def test_defaults_to_us_east_1(self):\n+        from agent.bedrock_adapter import resolve_bedrock_region\n+        assert resolve_bedrock_region({}) == \"us-east-1\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Tool conversion\n+# ---------------------------------------------------------------------------\n+\n+class TestConvertToolsToConverse:\n+    \"\"\"Test OpenAI → Bedrock Converse tool definition conversion.\"\"\"\n+\n+    def test_converts_single_tool(self):\n+        from agent.bedrock_adapter import convert_tools_to_converse\n+        tools = [{\n+            \"type\": \"function\",\n+            \"function\": {\n+                \"name\": \"read_file\",\n+                \"description\": \"Read a file from disk\",\n+                \"parameters\": {\n+                    \"type\": \"object\",\n+                    \"properties\": {\n+                        \"path\": {\"type\": \"string\", \"description\": \"File path\"},\n+                    },\n+                    \"required\": [\"path\"],\n+                },\n+            },\n+        }]\n+        result = convert_tools_to_converse(tools)\n+        assert len(result) == 1\n+        spec = result[0][\"toolSpec\"]\n+        assert spec[\"name\"] == \"read_file\"\n+        assert spec[\"description\"] == \"Read a file from disk\"\n+        assert spec[\"inputSchema\"][\"json\"][\"type\"] == \"object\"\n+        assert \"path\" in spec[\"inputSchema\"][\"json\"][\"properties\"]\n+\n+    def test_converts_multiple_tools(self):\n+        from agent.bedrock_adapter import convert_tools_to_converse\n+        tools = [\n+            {\"type\": \"function\", \"function\": {\"name\": \"tool_a\", \"description\": \"A\", \"parameters\": {}}},\n+            {\"type\": \"function\", \"function\": {\"name\": \"tool_b\", \"description\": \"B\", \"parameters\": {}}},\n+        ]\n+        result = convert_tools_to_converse(tools)\n+        assert len(result) == 2\n+        assert result[0][\"toolSpec\"][\"name\"] == \"tool_a\"\n+        assert result[1][\"toolSpec\"][\"name\"] == \"tool_b\"\n+\n+    def test_empty_tools(self):\n+        from agent.bedrock_adapter import convert_tools_to_converse\n+        assert convert_tools_to_converse([]) == []\n+        assert convert_tools_to_converse(None) == []\n+\n+    def test_missing_parameters_gets_default(self):\n+        from agent.bedrock_adapter import convert_tools_to_converse\n+        tools = [{\"type\": \"function\", \"function\": {\"name\": \"noop\", \"description\": \"No-op\"}}]\n+        result = convert_tools_to_converse(tools)\n+        schema = result[0][\"toolSpec\"][\"inputSchema\"][\"json\"]\n+        assert schema == {\"type\": \"object\", \"properties\": {}}\n+\n+\n+# ---------------------------------------------------------------------------\n+# Message conversion: OpenAI → Converse\n+# ---------------------------------------------------------------------------\n+\n+class TestConvertMessagesToConverse:\n+    \"\"\"Test OpenAI message format → Bedrock Converse format conversion.\"\"\"\n+\n+    def test_extracts_system_prompt(self):\n+        from agent.bedrock_adapter import convert_messages_to_converse\n+        messages = [\n+            {\"role\": \"system\", \"content\": \"You are a helpful assistant.\"},\n+            {\"role\": \"user\", \"content\": \"Hello\"},\n+        ]\n+        system, msgs = convert_messages_to_converse(messages)\n+        assert system is not None\n+        assert len(system) == 1\n+        assert system[0][\"text\"] == \"You are a helpful assistant.\"\n+        assert len(msgs) == 1\n+        assert msgs[0][\"role\"] == \"user\"\n+\n+    def test_user_message_text(self):\n+        from agent.bedrock_adapter import convert_messages_to_converse\n+        messages = [{\"role\": \"user\", \"content\": \"What is 2+2?\"}]\n+        system, msgs = convert_messages_to_converse(messages)\n+        assert system is None\n+        assert len(msgs) == 1\n+        assert msgs[0][\"content\"][0][\"text\"] == \"What is 2+2?\"\n+\n+    def test_assistant_with_tool_calls(self):\n+        from agent.bedrock_adapter import convert_messages_to_converse\n+        messages = [\n+            {\"role\": \"user\", \"content\": \"Read the file\"},\n+            {\n+                \"role\": \"assistant\",\n+                \"content\": \"I'll read that file.\",\n+                \"tool_calls\": [{\n+                    \"id\": \"call_123\",\n+                    \"type\": \"function\",\n+                    \"function\": {\n+                        \"name\": \"read_file\",\n+                        \"arguments\": '{\"path\": \"/tmp/test.txt\"}',\n+                    },\n+                }],\n+            },\n+        ]\n+        system, msgs = convert_messages_to_converse(messages)\n+        # 3 messages: user, assistant, trailing user (Converse requires last=user)\n+        assert len(msgs) == 3\n+        assistant_content = msgs[1][\"content\"]\n+        # Should have text block + toolUse block\n+        assert any(\"text\" in b for b in assistant_content)\n+        tool_use_blocks = [b for b in assistant_content if \"toolUse\" in b]\n+        assert len(tool_use_blocks) == 1\n+        assert tool_use_blocks[0][\"toolUse\"][\"name\"] == \"read_file\"\n+        assert tool_use_blocks[0][\"toolUse\"][\"toolUseId\"] == \"call_123\"\n+        assert tool_use_blocks[0][\"toolUse\"][\"input\"] == {\"path\": \"/tmp/test.txt\"}\n+\n+    def test_tool_result_becomes_user_message(self):\n+        from agent.bedrock_adapter import convert_messages_to_converse\n+        messages = [\n+            {\"role\": \"user\", \"content\": \"Read it\"},\n+            {\"role\": \"assistant\", \"content\": None, \"tool_calls\": [{\n+                \"id\": \"call_1\", \"type\": \"function\",\n+                \"function\": {\"name\": \"read_file\", \"arguments\": \"{}\"},\n+            }]},\n+            {\"role\": \"tool\", \"tool_call_id\": \"call_1\", \"content\": \"file contents here\"},\n+        ]\n+        system, msgs = convert_messages_to_converse(messages)\n+        # Tool result should be in a user-role message\n+        tool_result_msg = [m for m in msgs if m[\"role\"] == \"user\" and any(\n+            \"toolResult\" in b for b in m[\"content\"]\n+        )]\n+        assert len(tool_result_msg) == 1\n+        tr = [b for b in tool_result_msg[0][\"content\"] if \"toolResult\" in b][0]\n+        assert tr[\"toolResult\"][\"toolUseId\"] == \"call_1\"\n+        assert tr[\"toolResult\"][\"content\"][0][\"text\"] == \"file contents here\"\n+\n+    def test_merges_consecutive_user_messages(self):\n+        from agent.bedrock_adapter import convert_messages_to_converse\n+        messages = [\n+            {\"role\": \"user\", \"content\": \"First\"},\n+            {\"role\": \"user\", \"content\": \"Second\"},\n+        ]\n+        system, msgs = convert_messages_to_converse(messages)\n+        # Should be merged into one user message (Converse requires alternation)\n+        assert len(msgs) == 1\n+        assert msgs[0][\"role\"] == \"user\"\n+        texts = [b[\"text\"] for b in msgs[0][\"content\"] if \"text\" in b]\n+        assert \"First\" in texts\n+        assert \"Second\" in texts\n+\n+    def test_merges_consecutive_assistant_messages(self):\n+        from agent.bedrock_adapter import convert_messages_to_converse\n+        messages = [\n+            {\"role\": \"user\", \"content\": \"Hi\"},\n+            {\"role\": \"assistant\", \"content\": \"Part 1\"},\n+            {\"role\": \"assistant\", \"content\": \"Part 2\"},\n+        ]\n+        system, msgs = convert_messages_to_converse(messages)\n+        assistant_msgs = [m for m in msgs if m[\"role\"] == \"assistant\"]\n+        assert len(assistant_msgs) == 1\n+\n+    def test_first_message_must_be_user(self):\n+        from agent.bedrock_adapter import convert_messages_to_converse\n+        messages = [\n+            {\"role\": \"assistant\", \"content\": \"I'm ready\"},\n+            {\"role\": \"user\", \"content\": \"Go\"},\n+        ]\n+        system, msgs = convert_messages_to_converse(messages)\n+        assert msgs[0][\"role\"] == \"user\"\n+\n+    def test_last_message_must_be_user(self):\n+        from agent.bedrock_adapter import convert_messages_to_converse\n+        messages = [\n+            {\"role\": \"user\", \"content\": \"Hi\"},\n+            {\"role\": \"assistant\", \"content\": \"Hello\"},\n+        ]\n+        system, msgs = convert_messages_to_converse(messages)\n+        assert msgs[-1][\"role\"] == \"user\"\n+\n+    def test_empty_content_gets_placeholder(self):\n+        from agent.bedrock_adapter import convert_messages_to_converse\n+        messages = [{\"role\": \"user\", \"content\": \"\"}]\n+        system, msgs = convert_messages_to_converse(messages)\n+        # Empty string should get a space placeholder\n+        assert msgs[0][\"content\"][0][\"text\"].strip() != \"\" or msgs[0][\"content\"][0][\"text\"] == \" \"\n+\n+    def test_image_data_url_converted(self):\n+        from agent.bedrock_adapter import convert_messages_to_converse\n+        messages = [{\n+            \"role\": \"user\",\n+            \"content\": [\n+                {\"type\": \"text\", \"text\": \"What's in this image?\"},\n+                {\"type\": \"image_url\", \"image_url\": {\n+                    \"url\": \"data:image/png;base64,iVBORw0KGgo=\",\n+                }},\n+            ],\n+        }]\n+        system, msgs = convert_messages_to_converse(messages)\n+        content = msgs[0][\"content\"]\n+        assert any(\"text\" in b for b in content)\n+        image_blocks = [b for b in content if \"image\" in b]\n+        assert len(image_blocks) == 1\n+        assert image_blocks[0][\"image\"][\"format\"] == \"png\"\n+\n+    def test_multiple_system_messages_merged(self):\n+        from agent.bedrock_adapter import convert_messages_to_converse\n+        messages = [\n+            {\"role\": \"system\", \"content\": \"Rule 1\"},\n+            {\"role\": \"system\", \"content\": \"Rule 2\"},\n+            {\"role\": \"user\", \"content\": \"Go\"},\n+        ]\n+        system, msgs = convert_messages_to_converse(messages)\n+        assert system is not None\n+        assert len(system) == 2\n+        assert system[0][\"text\"] == \"Rule 1\"\n+        assert system[1][\"text\"] == \"Rule 2\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Response normalization: Converse → OpenAI\n+# ---------------------------------------------------------------------------\n+\n+class TestNormalizeConverseResponse:\n+    \"\"\"Test Bedrock Converse response → OpenAI format conversion.\"\"\"\n+\n+    def test_text_response(self):\n+        from agent.bedrock_adapter import normalize_converse_response\n+        response = {\n+            \"output\": {\n+                \"message\": {\n+                    \"role\": \"assistant\",\n+                    \"content\": [{\"text\": \"Hello, world!\"}],\n+                },\n+            },\n+            \"stopReason\": \"end_turn\",\n+            \"usage\": {\"inputTokens\": 10, \"outputTokens\": 5},\n+        }\n+        result = normalize_converse_response(response)\n+        assert result.choices[0].message.content == \"Hello, world!\"\n+        assert result.choices[0].message.tool_calls is None\n+        assert result.choices[0].finish_reason == \"stop\"\n+        assert result.usage.prompt_tokens == 10\n+        assert result.usage.completion_tokens == 5\n+        assert result.usage.total_tokens == 15\n+\n+    def test_tool_use_response(self):\n+        from agent.bedrock_adapter import normalize_converse_response\n+        response = {\n+            \"output\": {\n+                \"message\": {\n+                    \"role\": \"assistant\",\n+                    \"content\": [\n+                        {\"text\": \"I'll read that file.\"},\n+                        {\n+                            \"toolUse\": {\n+                                \"toolUseId\": \"call_abc\",\n+                                \"name\": \"read_file\",\n+                                \"input\": {\"path\": \"/tmp/test.txt\"},\n+                            },\n+                        },\n+                    ],\n+                },\n+            },\n+            \"stopReason\": \"tool_use\",\n+            \"usage\": {\"inputTokens\": 20, \"outputTokens\": 15},\n+        }\n+        result = normalize_converse_response(response)\n+        assert result.choices[0].message.content == \"I'll read that file.\"\n+        assert result.choices[0].finish_reason == \"tool_calls\"\n+        tool_calls = result.choices[0].message.tool_calls\n+        assert len(tool_calls) == 1\n+        assert tool_calls[0].id == \"call_abc\"\n+        assert tool_calls[0].function.name == \"read_file\"\n+        assert json.loads(tool_calls[0].function.arguments) == {\"path\": \"/tmp/test.txt\"}\n+\n+    def test_multiple_tool_calls(self):\n+        from agent.bedrock_adapter import normalize_converse_response\n+        response = {\n+            \"output\": {\n+                \"message\": {\n+                    \"role\": \"assistant\",\n+                    \"content\": [\n+                        {\"toolUse\": {\"toolUseId\": \"c1\", \"name\": \"tool_a\", \"input\": {}}},\n+                        {\"toolUse\": {\"toolUseId\": \"c2\", \"name\": \"tool_b\", \"input\": {\"x\": 1}}},\n+                    ],\n+                },\n+            },\n+            \"stopReason\": \"tool_use\",\n+            \"usage\": {\"inputTokens\": 0, \"outputTokens\": 0},\n+        }\n+        result = normalize_converse_response(response)\n+        assert len(result.choices[0].message.tool_calls) == 2\n+        assert result.choices[0].finish_reason == \"tool_calls\"\n+\n+    def test_stop_reason_mapping(self):\n+        from agent.bedrock_adapter import _converse_stop_reason_to_openai\n+        assert _converse_stop_reason_to_openai(\"end_turn\") == \"stop\"\n+        assert _converse_stop_reason_to_openai(\"stop_sequence\") == \"stop\"\n+        assert _converse_stop_reason_to_openai(\"tool_use\") == \"tool_calls\"\n+        assert _converse_stop_reason_to_openai(\"max_tokens\") == \"length\"\n+        assert _converse_stop_reason_to_openai(\"content_filtered\") == \"content_filter\"\n+        assert _converse_stop_reason_to_openai(\"guardrail_intervened\") == \"content_filter\"\n+        assert _converse_stop_reason_to_openai(\"unknown_reason\") == \"stop\"\n+\n+    def test_empty_content(self):\n+        from agent.bedrock_adapter import normalize_converse_response\n+        response = {\n+            \"output\": {\"message\": {\"role\": \"assistant\", \"content\": []}},\n+            \"stopReason\": \"end_turn\",\n+            \"usage\": {\"inputTokens\": 0, \"outputTokens\": 0},\n+        }\n+        result = normalize_converse_response(response)\n+        assert result.choices[0].message.content is None\n+        assert result.choices[0].message.tool_calls is None\n+\n+    def test_tool_calls_override_stop_finish_reason(self):\n+        \"\"\"When tool_calls are present but stopReason is end_turn, finish_reason should be tool_calls.\"\"\"\n+        from agent.bedrock_adapter import normalize_converse_response\n+        response = {\n+            \"output\": {\n+                \"message\": {\n+                    \"role\": \"assistant\",\n+                    \"content\": [\n+                        {\"toolUse\": {\"toolUseId\": \"c1\", \"name\": \"t\", \"input\": {}}},\n+                    ],\n+                },\n+            },\n+            \"stopReason\": \"end_turn\",  # Bedrock sometimes sends this with tool_use\n+            \"usage\": {\"inputTokens\": 0, \"outputTokens\": 0},\n+        }\n+        result = normalize_converse_response(response)\n+        assert result.choices[0].finish_reason == \"tool_calls\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Streaming response normalization\n+# ---------------------------------------------------------------------------\n+\n+class TestNormalizeConverseStreamEvents:\n+    \"\"\"Test Bedrock ConverseStream event → OpenAI format conversion.\"\"\"\n+\n+    def test_text_stream(self):\n+        from agent.bedrock_adapter import normalize_converse_stream_events\n+        events = {\"stream\": [\n+            {\"messageStart\": {\"role\": \"assistant\"}},\n+            {\"contentBlockStart\": {\"contentBlockIndex\": 0, \"start\": {}}},\n+            {\"contentBlockDelta\": {\"contentBlockIndex\": 0, \"delta\": {\"text\": \"Hello\"}}},\n+            {\"contentBlockDelta\": {\"contentBlockIndex\": 0, \"delta\": {\"text\": \", world!\"}}},\n+            {\"contentBlockStop\": {\"contentBlockIndex\": 0}},\n+            {\"messageStop\": {\"stopReason\": \"end_turn\"}},\n+            {\"metadata\": {\"usage\": {\"inputTokens\": 5, \"outputTokens\": 3}}},\n+        ]}\n+        result = normalize_converse_stream_events(events)\n+        assert result.choices[0].message.content == \"Hello, world!\"\n+        assert result.choices[0].finish_reason == \"stop\"\n+        assert result.usage.prompt_tokens == 5\n+        assert result.usage.completion_tokens == 3\n+\n+    def test_tool_use_stream(self):\n+        from agent.bedrock_adapter import normalize_converse_stream_events\n+        events = {\"stream\": [\n+            {\"messageStart\": {\"role\": \"assistant\"}},\n+            {\"contentBlockStart\": {\"contentBlockIndex\": 0, \"start\": {\n+                \"toolUse\": {\"toolUseId\": \"call_1\", \"name\": \"read_file\"},\n+            }}},\n+            {\"contentBlockDelta\": {\"contentBlockIndex\": 0, \"delta\": {\n+                \"toolUse\": {\"input\": '{\"path\":'},\n+            }}},\n+            {\"contentBlockDelta\": {\"contentBlockIndex\": 0, \"delta\": {\n+                \"toolUse\": {\"input\": '\"/tmp/f\"}'},\n+            }}},\n+            {\"contentBlockStop\": {\"contentBlockIndex\": 0}},\n+            {\"messageStop\": {\"stopReason\": \"tool_use\"}},\n+            {\"metadata\": {\"usage\": {\"inputTokens\": 10, \"outputTokens\": 8}}},\n+        ]}\n+        result = normalize_converse_stream_events(events)\n+        assert result.choices[0].finish_reason == \"tool_calls\"\n+        tc = result.choices[0].message.tool_calls\n+        assert len(tc) == 1\n+        assert tc[0].id == \"call_1\"\n+        assert tc[0].function.name == \"read_file\"\n+        assert json.loads(tc[0].function.arguments) == {\"path\": \"/tmp/f\"}\n+\n+    def test_mixed_text_and_tool_stream(self):\n+        from agent.bedrock_adapter import normalize_converse_stream_events\n+        events = {\"stream\": [\n+            {\"messageStart\": {\"role\": \"assistant\"}},\n+            # Text block\n+            {\"contentBlockStart\": {\"contentBlockIndex\": 0, \"start\": {}}},\n+            {\"contentBlockDelta\": {\"contentBlockIndex\": 0, \"delta\": {\"text\": \"Let me check.\"}}},\n+            {\"contentBlockStop\": {\"contentBlockIndex\": 0}},\n+            # Tool block\n+            {\"contentBlockStart\": {\"contentBlockIndex\": 1, \"start\": {\n+                \"toolUse\": {\"toolUseId\": \"c1\", \"name\": \"search\"},\n+            }}},\n+            {\"contentBlockDelta\": {\"contentBlockIndex\": 1, \"delta\": {\n+                \"toolUse\": {\"input\": '{\"q\":\"test\"}'},\n+            }}},\n+            {\"contentBlockStop\": {\"contentBlockIndex\": 1}},\n+            {\"messageStop\": {\"stopReason\": \"tool_use\"}},\n+            {\"metadata\": {\"usage\": {\"inputTokens\": 0, \"outputTokens\": 0}}},\n+        ]}\n+        result = normalize_converse_stream_events(events)\n+        assert result.choices[0].message.content == \"Let me check.\"\n+        assert len(result.choices[0].message.tool_calls) == 1\n+\n+    def test_empty_stream(self):\n+        from agent.bedrock_adapter import normalize_converse_stream_events\n+        events = {\"stream\": [\n+            {\"messageStart\": {\"role\": \"assistant\"}},\n+            {\"messageStop\": {\"stopReason\": \"end_turn\"}},\n+            {\"metadata\": {\"usage\": {\"inputTokens\": 0, \"outputTokens\": 0}}},\n+        ]}\n+        result = normalize_converse_stream_events(events)\n+        assert result.choices[0].message.content is None\n+        assert result.choices[0].message.tool_calls is None\n+\n+\n+# ---------------------------------------------------------------------------\n+# build_converse_kwargs\n+# ---------------------------------------------------------------------------\n+\n+class TestBuildConverseKwargs:\n+    \"\"\"Test the high-level kwargs builder for Converse API calls.\"\"\"\n+\n+    def test_basic_kwargs(self):\n+        from agent.bedrock_adapter import build_converse_kwargs\n+        messages = [\n+            {\"role\": \"system\", \"content\": \"Be helpful.\"},\n+            {\"role\": \"user\", \"content\": \"Hi\"},\n+        ]\n+        kwargs = build_converse_kwargs(\n+            model=\"anthropic.claude-sonnet-4-6-20250514-v1:0\",\n+            messages=messages,\n+            max_tokens=1024,\n+        )\n+        assert kwargs[\"modelId\"] == \"anthropic.claude-sonnet-4-6-20250514-v1:0\"\n+        assert kwargs[\"inferenceConfig\"][\"maxTokens\"] == 1024\n+        assert kwargs[\"system\"] is not None\n+        assert len(kwargs[\"messages\"]) >= 1\n+\n+    def test_includes_tools(self):\n+        from agent.bedrock_adapter import build_converse_kwargs\n+        tools = [{\"type\": \"function\", \"function\": {\n+            \"name\": \"test\", \"description\": \"Test\", \"parameters\": {},\n+        }}]\n+        kwargs = build_converse_kwargs(\n+            model=\"test-model\", messages=[{\"role\": \"user\", \"content\": \"Hi\"}],\n+            tools=tools,\n+        )\n+        assert \"toolConfig\" in kwargs\n+        assert len(kwargs[\"toolConfig\"][\"tools\"]) == 1\n+\n+    def test_includes_temperature_and_top_p(self):\n+        from agent.bedrock_adapter import build_converse_kwargs\n+        kwargs = build_converse_kwargs(\n+            model=\"test-model\", messages=[{\"role\": \"user\", \"content\": \"Hi\"}],\n+            temperature=0.7, top_p=0.9,\n+        )\n+        assert kwargs[\"inferenceConfig\"][\"temperature\"] == 0.7\n+        assert kwargs[\"inferenceConfig\"][\"topP\"] == 0.9\n+\n+    def test_includes_guardrail_config(self):\n+        from agent.bedrock_adapter import build_converse_kwargs\n+        guardrail = {\n+            \"guardrailIdentifier\": \"gr-123\",\n+            \"guardrailVersion\": \"1\",\n+        }\n+        kwargs = build_converse_kwargs(\n+            model=\"test-model\", messages=[{\"role\": \"user\", \"content\": \"Hi\"}],\n+            guardrail_config=guardrail,\n+        )\n+        assert kwargs[\"guardrailConfig\"] == guardrail\n+\n+    def test_no_system_when_absent(self):\n+        from agent.bedrock_adapter import build_converse_kwargs\n+        kwargs = build_converse_kwargs(\n+            model=\"test-model\", messages=[{\"role\": \"user\", \"content\": \"Hi\"}],\n+        )\n+        assert \"system\" not in kwargs\n+\n+    def test_no_tool_config_when_empty(self):\n+        from agent.bedrock_adapter import build_converse_kwargs\n+        kwargs = build_converse_kwargs(\n+            model=\"test-model\", messages=[{\"role\": \"user\", \"content\": \"Hi\"}],\n+            tools=[],\n+        )\n+        assert \"toolConfig\" not in kwargs\n+\n+\n+# ---------------------------------------------------------------------------\n+# Model discovery\n+# ---------------------------------------------------------------------------\n+\n+class TestDiscoverBedrockModels:\n+    \"\"\"Test Bedrock model discovery with mocked AWS API calls.\"\"\"\n+\n+    def test_discovers_foundation_models(self):\n+        from agent.bedrock_adapter import discover_bedrock_models, reset_discovery_cache\n+        reset_discovery_cache()\n+\n+        mock_client = MagicMock()\n+        mock_client.list_foundation_models.return_value = {\n+            \"modelSummaries\": [\n+                {\n+                    \"modelId\": \"anthropic.claude-sonnet-4-6-20250514-v1:0\",\n+                    \"modelName\": \"Claude Sonnet 4.6\",\n+                    \"providerName\": \"Anthropic\",\n+                    \"inputModalities\": [\"TEXT\", \"IMAGE\"],\n+                    \"outputModalities\": [\"TEXT\"],\n+                    \"responseStreamingSupported\": True,\n+                    \"modelLifecycle\": {\"status\": \"ACTIVE\"},\n+                },\n+                {\n+                    \"modelId\": \"amazon.nova-pro-v1:0\",\n+                    \"modelName\": \"Nova Pro\",\n+                    \"providerName\": \"Amazon\",\n+                    \"inputModalities\": [\"TEXT\"],\n+                    \"outputModalities\": [\"TEXT\"],\n+                    \"responseStreamingSupported\": True,\n+                    \"modelLifecycle\": {\"status\": \"ACTIVE\"},\n+                },\n+            ],\n+        }\n+        mock_client.list_inference_profiles.return_value = {\n+            \"inferenceProfileSummaries\": [],\n+        }\n+\n+        with patch(\"agent.bedrock_adapter._get_bedrock_control_client\", return_value=mock_client):\n+            models = discover_bedrock_models(\"us-east-1\")\n+\n+        assert len(models) == 2\n+        ids = [m[\"id\"] for m in models]\n+        assert \"anthropic.claude-sonnet-4-6-20250514-v1:0\" in ids\n+        assert \"amazon.nova-pro-v1:0\" in ids\n+\n+    def test_filters_inactive_models(self):\n+        from agent.bedrock_adapter import discover_bedrock_models, reset_discovery_cache\n+        reset_discovery_cache()\n+\n+        mock_client = MagicMock()\n+        mock_client.list_foundation_models.return_value = {\n+            \"modelSummaries\": [\n+                {\n+                    \"modelId\": \"old-model\",\n+                    \"modelName\": \"Old\",\n+                    \"providerName\": \"Test\",\n+                    \"inputModalities\": [\"TEXT\"],\n+                    \"outputModalities\": [\"TEXT\"],\n+                    \"responseStreamingSupported\": True,\n+                    \"modelLifecycle\": {\"status\": \"LEGACY\"},\n+                },\n+            ],\n+        }\n+        mock_client.list_inference_profiles.return_value = {\"inferenceProfileSummaries\": []}\n+\n+        with patch(\"agent.bedrock_adapter._get_bedrock_control_client\", return_value=mock_client):\n+            models = discover_bedrock_models(\"us-east-1\")\n+\n+        assert len(models) == 0\n+\n+    def test_filters_non_streaming_models(self):\n+        from agent.bedrock_adapter import discover_bedrock_models, reset_discovery_cache\n+        reset_discovery_cache()\n+\n+        mock_client = MagicMock()\n+        mock_client.list_foundation_models.return_value = {\n+            \"modelSummaries\": [\n+                {\n+                    \"modelId\": \"embed-model\",\n+                    \"modelName\": \"Embeddings\",\n+                    \"providerName\": \"Test\",\n+                    \"inputModalities\": [\"TEXT\"],\n+                    \"outputModalities\": [\"EMBEDDING\"],\n+                    \"responseStreamingSupported\": False,\n+                    \"modelLifecycle\": {\"status\": \"ACTIVE\"},\n+                },\n+            ],\n+        }\n+        mock_client.list_inference_profiles.return_value = {\"inferenceProfileSummaries\": []}\n+\n+        with patch(\"agent.bedrock_adapter._get_bedrock_control_client\", return_value=mock_client):\n+            models = discover_bedrock_models(\"us-east-1\")\n+\n+        assert len(models) == 0\n+\n+    def test_provider_filter(self):\n+        from agent.bedrock_adapter import discover_bedrock_models, reset_discovery_cache\n+        reset_discovery_cache()\n+\n+        mock_client = MagicMock()\n+        mock_client.list_foundation_models.return_value = {\n+            \"modelSummaries\": [\n+                {\n+                    \"modelId\": \"anthropic.claude-v2\",\n+                    \"modelName\": \"Claude v2\",\n+                    \"providerName\": \"Anthropic\",\n+                    \"inputModalities\": [\"TEXT\"],\n+                    \"outputModalities\": [\"TEXT\"],\n+                    \"responseStreamingSupported\": True,\n+                    \"modelLifecycle\": {\"status\": \"ACTIVE\"},\n+                },\n+                {\n+                    \"modelId\": \"amazon.titan-text\",\n+                    \"modelName\": \"Titan\",\n+                    \"providerName\": \"Amazon\",\n+                    \"inputModalities\": [\"TEXT\"],\n+                    \"outputModalities\": [\"TEXT\"],\n+                    \"responseStreamingSupported\": True,\n+                    \"modelLifecycle\": {\"status\": \"ACTIVE\"},\n+                },\n+            ],\n+        }\n+        mock_client.list_inference_profiles.return_value = {\"inferenceProfileSummaries\": []}\n+\n+        with patch(\"agent.bedrock_adapter._get_bedrock_control_client\", return_value=mock_client):\n+            models = discover_bedrock_models(\"us-east-1\", provider_filter=[\"anthropic\"])\n+\n+        assert len(models) == 1\n+        assert models[0][\"id\"] == \"anthropic.claude-v2\"\n+\n+    def test_caches_results(self):\n+        from agent.bedrock_adapter import discover_bedrock_models, reset_discovery_cache\n+        reset_discovery_cache()\n+\n+        mock_client = MagicMock()\n+        mock_client.list_foundation_models.return_value = {\n+            \"modelSummaries\": [{\n+                \"modelId\": \"test-model\",\n+                \"modelName\": \"Test\",\n+                \"providerName\": \"Test\",\n+                \"inputModalities\": [\"TEXT\"],\n+                \"outputModalities\": [\"TEXT\"],\n+                \"responseStreamingSupported\": True,\n+                \"modelLifecycle\": {\"status\": \"ACTIVE\"},\n+            }],\n+        }\n+        mock_client.list_inference_profiles.return_value = {\"inferenceProfileSummaries\": []}\n+\n+        with patch(\"agent.bedrock_adapter._get_bedrock_control_client\", return_value=mock_client):\n+            first = discover_bedrock_models(\"us-east-1\")\n+            second = discover_bedrock_models(\"us-east-1\")\n+\n+        # Should only call the API once (second call uses cache)\n+        assert mock_client.list_foundation_models.call_count == 1\n+        assert first == second\n+\n+    def test_discovers_inference_profiles(self):\n+        from agent.bedrock_adapter import discover_bedrock_models, reset_discovery_cache\n+        reset_discovery_cache()\n+\n+        mock_client = MagicMock()\n+        mock_client.list_foundation_models.return_value = {\"modelSummaries\": []}\n+        mock_client.list_inference_profiles.return_value = {\n+            \"inferenceProfileSummaries\": [\n+                {\n+                    \"inferenceProfileId\": \"us.anthropic.claude-sonnet-4-6\",\n+                    \"inferenceProfileName\": \"US Claude Sonnet 4.6\",\n+                    \"status\": \"ACTIVE\",\n+                    \"models\": [{\"modelArn\": \"arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6\"}],\n+                },\n+            ],\n+        }\n+\n+        with patch(\"agent.bedrock_adapter._get_bedrock_control_client\", return_value=mock_client):\n+            models = discover_bedrock_models(\"us-east-1\")\n+\n+        assert len(models) == 1\n+        assert models[0][\"id\"] == \"us.anthropic.claude-sonnet-4-6\"\n+\n+    def test_global_profiles_sorted_first(self):\n+        from agent.bedrock_adapter import discover_bedrock_models, reset_discovery_cache\n+        reset_discovery_cache()\n+\n+        mock_client = MagicMock()\n+        mock_client.list_foundation_models.return_value = {\n+            \"modelSummaries\": [{\n+                \"modelId\": \"anthropic.claude-v2\",\n+                \"modelName\": \"Claude v2\",\n+                \"providerName\": \"Anthropic\",\n+                \"inputModalities\": [\"TEXT\"],\n+                \"outputModalities\": [\"TEXT\"],\n+                \"responseStreamingSupported\": True,\n+                \"modelLifecycle\": {\"status\": \"ACTIVE\"},\n+            }],\n+        }\n+        mock_client.list_inference_profiles.return_value = {\n+            \"inferenceProfileSummaries\": [{\n+                \"inferenceProfileId\": \"global.anthropic.claude-v2\",\n+                \"inferenceProfileName\": \"Global Claude v2\",\n+                \"status\": \"ACTIVE\",\n+                \"models\": [],\n+            }],\n+        }\n+\n+        with patch(\"agent.bedrock_adapter._get_bedrock_control_client\", return_value=mock_client):\n+            models = discover_bedrock_models(\"us-east-1\")\n+\n+        assert models[0][\"id\"] == \"global.anthropic.claude-v2\"\n+\n+    def test_handles_api_error_gracefully(self):\n+        from agent.bedrock_adapter import discover_bedrock_models, reset_discovery_cache\n+        reset_discovery_cache()\n+\n+        with patch(\"agent.bedrock_adapter._get_bedrock_control_client\", side_effect=Exception(\"No creds\")):\n+            models = discover_bedrock_models(\"us-east-1\")\n+\n+        assert models == []\n+\n+\n+class TestExtractProviderFromArn:\n+    def test_extracts_anthropic(self):\n+        from agent.bedrock_adapter import _extract_provider_from_arn\n+        arn = \"arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6\"\n+        assert _extract_provider_from_arn(arn) == \"anthropic\"\n+\n+    def test_extracts_amazon(self):\n+        from agent.bedrock_adapter import _extract_provider_from_arn\n+        arn = \"arn:aws:bedrock:us-east-1::foundation-model/amazon.nova-pro-v1:0\"\n+        assert _extract_provider_from_arn(arn) == \"amazon\"\n+\n+    def test_returns_empty_for_invalid_arn(self):\n+        from agent.bedrock_adapter import _extract_provider_from_arn\n+        assert _extract_provider_from_arn(\"not-an-arn\") == \"\"\n+        assert _extract_provider_from_arn(\"\") == \"\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Client cache management\n+# ---------------------------------------------------------------------------\n+\n+class TestClientCache:\n+    def test_reset_clears_caches(self):\n+        from agent.bedrock_adapter import (\n+            _bedrock_runtime_client_cache,\n+            _bedrock_control_client_cache,\n+            reset_client_cache,\n+        )\n+        _bedrock_runtime_client_cache[\"test\"] = \"dummy\"\n+        _bedrock_control_client_cache[\"test\"] = \"dummy\"\n+        reset_client_cache()\n+        assert len(_bedrock_runtime_client_cache) == 0\n+        assert len(_bedrock_control_client_cache) == 0\n+\n+\n+# ---------------------------------------------------------------------------\n+# Streaming with callbacks\n+# ---------------------------------------------------------------------------\n+\n+class TestStreamConverseWithCallbacks:\n+    \"\"\"Test real-time streaming with delta callbacks.\"\"\"\n+\n+    def test_text_deltas_fire_callback(self):\n+        from agent.bedrock_adapter import stream_converse_with_callbacks\n+        deltas = []\n+        events = {\"stream\": [\n+            {\"messageStart\": {\"role\": \"assistant\"}},\n+            {\"contentBlockStart\": {\"contentBlockIndex\": 0, \"start\": {}}},\n+            {\"contentBlockDelta\": {\"contentBlockIndex\": 0, \"delta\": {\"text\": \"Hello\"}}},\n+            {\"contentBlockDelta\": {\"contentBlockIndex\": 0, \"delta\": {\"text\": \" world\"}}},\n+            {\"contentBlockStop\": {\"contentBlockIndex\": 0}},\n+            {\"messageStop\": {\"stopReason\": \"end_turn\"}},\n+            {\"metadata\": {\"usage\": {\"inputTokens\": 5, \"outputTokens\": 3}}},\n+        ]}\n+        result = stream_converse_with_callbacks(\n+            events, on_text_delta=lambda t: deltas.append(t),\n+        )\n+        assert deltas == [\"Hello\", \" world\"]\n+        assert result.choices[0].message.content == \"Hello world\"\n+\n+    def test_text_deltas_suppressed_when_tool_use_present(self):\n+        \"\"\"Text deltas should NOT fire when tool_use blocks are present.\"\"\"\n+        from agent.bedrock_adapter import stream_converse_with_callbacks\n+        deltas = []\n+        events = {\"stream\": [\n+            {\"messageStart\": {\"role\": \"assistant\"}},\n+            {\"contentBlockStart\": {\"contentBlockIndex\": 0, \"start\": {}}},\n+            {\"contentBlockDelta\": {\"contentBlockIndex\": 0, \"delta\": {\"text\": \"Let me check.\"}}},\n+            {\"contentBlockStop\": {\"contentBlockIndex\": 0}},\n+            {\"contentBlockStart\": {\"contentBlockIndex\": 1, \"start\": {\n+                \"toolUse\": {\"toolUseId\": \"c1\", \"name\": \"search\"},\n+            }}},\n+            {\"contentBlockDelta\": {\"contentBlockIndex\": 1, \"delta\": {\n+                \"toolUse\": {\"input\": '{\"q\":\"test\"}'},\n+            }}},\n+            {\"contentBlockStop\": {\"contentBlockIndex\": 1}},\n+            {\"messageStop\": {\"stopReason\": \"tool_use\"}},\n+            {\"metadata\": {\"usage\": {\"inputTokens\": 0, \"outputTokens\": 0}}},\n+        ]}\n+        result = stream_converse_with_callbacks(\n+            events, on_text_delta=lambda t: deltas.append(t),\n+        )\n+        # Text delta for \"Let me check.\" should fire (before tool_use was seen)\n+        assert \"Let me check.\" in deltas\n+        # But the result should still have both text and tool calls\n+        assert result.choices[0].message.content == \"Let me check.\"\n+        assert len(result.choices[0].message.tool_calls) == 1\n+\n+    def test_tool_start_callback_fires(self):\n+        from agent.bedrock_adapter import stream_converse_with_callbacks\n+        tools_started = []\n+        events = {\"stream\": [\n+            {\"messageStart\": {\"role\": \"assistant\"}},\n+            {\"contentBlockStart\": {\"contentBlockIndex\": 0, \"start\": {\n+                \"toolUse\": {\"toolUseId\": \"c1\", \"name\": \"read_file\"},\n+            }}},\n+            {\"contentBlockDelta\": {\"contentBlockIndex\": 0, \"delta\": {\n+                \"toolUse\": {\"input\": '{\"path\":\"/tmp/f\"}'},\n+            }}},\n+            {\"contentBlockStop\": {\"contentBlockIndex\": 0}},\n+            {\"messageStop\": {\"stopReason\": \"tool_use\"}},\n+            {\"metadata\": {\"usage\": {\"inputTokens\": 0, \"outputTokens\": 0}}},\n+        ]}\n+        result = stream_converse_with_callbacks(\n+            events, on_tool_start=lambda name: tools_started.append(name),\n+        )\n+        assert tools_started == [\"read_file\"]\n+\n+    def test_interrupt_stops_processing(self):\n+        from agent.bedrock_adapter import stream_converse_with_callbacks\n+        deltas = []\n+        call_count = {\"n\": 0}\n+        events = {\"stream\": [\n+            {\"messageStart\": {\"role\": \"assistant\"}},\n+            {\"contentBlockDelta\": {\"contentBlockIndex\": 0, \"delta\": {\"text\": \"A\"}}},\n+            {\"contentBlockDelta\": {\"contentBlockIndex\": 0, \"delta\": {\"text\": \"B\"}}},\n+            {\"contentBlockDelta\": {\"contentBlockIndex\": 0, \"delta\": {\"text\": \"C\"}}},\n+            {\"messageStop\": {\"stopReason\": \"end_turn\"}},\n+            {\"metadata\": {\"usage\": {\"inputTokens\": 0, \"outputTokens\": 0}}},\n+        ]}\n+\n+        def check_interrupt():\n+            call_count[\"n\"] += 1\n+            return call_count[\"n\"] >= 3  # Interrupt after 2 events\n+\n+        result = stream_converse_with_callbacks(\n+            events,\n+            on_text_delta=lambda t: deltas.append(t),\n+            on_interrupt_check=check_interrupt,\n+        )\n+        # Should have processed fewer than all deltas\n+        assert len(deltas) < 3\n+\n+    def test_reasoning_delta_callback(self):\n+        from agent.bedrock_adapter import stream_converse_with_callbacks\n+        reasoning = []\n+        events = {\"stream\": [\n+            {\"messageStart\": {\"role\": \"assistant\"}},\n+            {\"contentBlockDelta\": {\"contentBlockIndex\": 0, \"delta\": {\n+                \"reasoningContent\": {\"text\": \"Let me think...\"},\n+            }}},\n+            {\"contentBlockDelta\": {\"contentBlockIndex\": 1, \"delta\": {\"text\": \"Answer.\"}}},\n+            {\"contentBlockStop\": {\"contentBlockIndex\": 1}},\n+            {\"messageStop\": {\"stopReason\": \"end_turn\"}},\n+            {\"metadata\": {\"usage\": {\"inputTokens\": 0, \"outputTokens\": 0}}},\n+        ]}\n+        result = stream_converse_with_callbacks(\n+            events, on_reasoning_delta=lambda t: reasoning.append(t),\n+        )\n+        assert reasoning == [\"Let me think...\"]\n+\n+\n+# ---------------------------------------------------------------------------\n+# Guardrail config in build_converse_kwargs\n+# ---------------------------------------------------------------------------\n+\n+class TestGuardrailConfig:\n+    \"\"\"Test that guardrail configuration is correctly passed through.\"\"\"\n+\n+    def test_guardrail_included_in_kwargs(self):\n+        from agent.bedrock_adapter import build_converse_kwargs\n+        guardrail = {\n+            \"guardrailIdentifier\": \"gr-abc123\",\n+            \"guardrailVersion\": \"1\",\n+            \"streamProcessingMode\": \"async\",\n+            \"trace\": \"enabled\",\n+        }\n+        kwargs = build_converse_kwargs(\n+            model=\"test-model\",\n+            messages=[{\"role\": \"user\", \"content\": \"Hi\"}],\n+            guardrail_config=guardrail,\n+        )\n+        assert kwargs[\"guardrailConfig\"] == guardrail\n+\n+    def test_no_guardrail_when_none(self):\n+        from agent.bedrock_adapter import build_converse_kwargs\n+        kwargs = build_converse_kwargs(\n+            model=\"test-model\",\n+            messages=[{\"role\": \"user\", \"content\": \"Hi\"}],\n+            guardrail_config=None,\n+        )\n+        assert \"guardrailConfig\" not in kwargs\n+\n+    def test_no_guardrail_when_empty_dict(self):\n+        from agent.bedrock_adapter import build_converse_kwargs\n+        kwargs = build_converse_kwargs(\n+            model=\"test-model\",\n+            messages=[{\"role\": \"user\", \"content\": \"Hi\"}],\n+            guardrail_config={},\n+        )\n+        # Empty dict is falsy, should not be included\n+        assert \"guardrailConfig\" not in kwargs\n+\n+\n+# ---------------------------------------------------------------------------\n+# Error classification\n+# ---------------------------------------------------------------------------\n+\n+class TestBedrockErrorClassification:\n+    \"\"\"Test Bedrock-specific error classification.\"\"\"\n+\n+    def test_context_overflow_validation_exception(self):\n+        from agent.bedrock_adapter import classify_bedrock_error\n+        assert classify_bedrock_error(\n+            \"ValidationException: input is too long for model\"\n+        ) == \"context_overflow\"\n+\n+    def test_context_overflow_max_tokens(self):\n+        from agent.bedrock_adapter import classify_bedrock_error\n+        assert classify_bedrock_error(\n+            \"ValidationException: exceeds the maximum number of input tokens\"\n+        ) == \"context_overflow\"\n+\n+    def test_context_overflow_stream_error(self):\n+        from agent.bedrock_adapter import classify_bedrock_error\n+        assert classify_bedrock_error(\n+            \"ModelStreamErrorException: Input is too long\"\n+        ) == \"context_overflow\"\n+\n+    def test_rate_limit_throttling(self):\n+        from agent.bedrock_adapter import classify_bedrock_error\n+        assert classify_bedrock_error(\"ThrottlingException: Rate exceeded\") == \"rate_limit\"\n+\n+    def test_rate_limit_concurrent(self):\n+        from agent.bedrock_adapter import classify_bedrock_error\n+        assert classify_bedrock_error(\"Too many concurrent requests\") == \"rate_limit\"\n+\n+    def test_overloaded_not_ready(self):\n+        from agent.bedrock_adapter import classify_bedrock_error\n+        assert classify_bedrock_error(\"ModelNotReadyException\") == \"overloaded\"\n+\n+    def test_overloaded_timeout(self):\n+        from agent.bedrock_adapter import classify_bedrock_error\n+        assert classify_bedrock_error(\"ModelTimeoutException\") == \"overloaded\"\n+\n+    def test_unknown_error(self):\n+        from agent.bedrock_adapter import classify_bedrock_error\n+        assert classify_bedrock_error(\"SomeRandomError: something went wrong\") == \"unknown\"\n+\n+\n+class TestBedrockContextLength:\n+    \"\"\"Test Bedrock model context length lookup.\"\"\"\n+\n+    def test_claude_opus_4_6(self):\n+        from agent.bedrock_adapter import get_bedrock_context_length\n+        assert get_bedrock_context_length(\"anthropic.claude-opus-4-6-20250514-v1:0\") == 200_000\n+\n+    def test_claude_sonnet_versioned(self):\n+        from agent.bedrock_adapter import get_bedrock_context_length\n+        assert get_bedrock_context_length(\"anthropic.claude-sonnet-4-6-20250514-v1:0\") == 200_000\n+\n+    def test_nova_pro(self):\n+        from agent.bedrock_adapter import get_bedrock_context_length\n+        assert get_bedrock_context_length(\"amazon.nova-pro-v1:0\") == 300_000\n+\n+    def test_nova_micro(self):\n+        from agent.bedrock_adapter import get_bedrock_context_length\n+        assert get_bedrock_context_length(\"amazon.nova-micro-v1:0\") == 128_000\n+\n+    def test_unknown_model_gets_default(self):\n+        from agent.bedrock_adapter import get_bedrock_context_length, BEDROCK_DEFAULT_CONTEXT_LENGTH\n+        assert get_bedrock_context_length(\"unknown.model-v1:0\") == BEDROCK_DEFAULT_CONTEXT_LENGTH\n+\n+    def test_inference_profile_resolves(self):\n+        from agent.bedrock_adapter import get_bedrock_context_length\n+        # Cross-region inference profiles contain the base model ID\n+        assert get_bedrock_context_length(\"us.anthropic.claude-sonnet-4-6\") == 200_000\n+\n+    def test_longest_prefix_wins(self):\n+        from agent.bedrock_adapter import get_bedrock_context_length\n+        # \"anthropic.claude-3-5-sonnet\" should match before \"anthropic.claude-3\"\n+        assert get_bedrock_context_length(\"anthropic.claude-3-5-sonnet-20240620-v1:0\") == 200_000\n+\n+\n+# ---------------------------------------------------------------------------\n+# Tool-calling capability detection\n+# ---------------------------------------------------------------------------\n+\n+class TestModelSupportsToolUse:\n+    \"\"\"Test non-tool-calling model detection.\"\"\"\n+\n+    def test_claude_supports_tools(self):\n+        from agent.bedrock_adapter import _model_supports_tool_use\n+        assert _model_supports_tool_use(\"us.anthropic.claude-sonnet-4-6\") is True\n+\n+    def test_nova_supports_tools(self):\n+        from agent.bedrock_adapter import _model_supports_tool_use\n+        assert _model_supports_tool_use(\"us.amazon.nova-pro-v1:0\") is True\n+\n+    def test_deepseek_v3_supports_tools(self):\n+        from agent.bedrock_adapter import _model_supports_tool_use\n+        assert _model_supports_tool_use(\"deepseek.v3.2\") is True\n+\n+    def test_llama_supports_tools(self):\n+        from agent.bedrock_adapter import _model_supports_tool_use\n+        assert _model_supports_tool_use(\"us.meta.llama4-scout-17b-instruct-v1:0\") is True\n+\n+    def test_deepseek_r1_no_tools(self):\n+        from agent.bedrock_adapter import _model_supports_tool_use\n+        assert _model_supports_tool_use(\"us.deepseek.r1-v1:0\") is False\n+\n+    def test_deepseek_r1_alt_format_no_tools(self):\n+        from agent.bedrock_adapter import _model_supports_tool_use\n+        assert _model_supports_tool_use(\"deepseek-r1\") is False\n+\n+    def test_stability_no_tools(self):\n+        from agent.bedrock_adapter import _model_supports_tool_use\n+        assert _model_supports_tool_use(\"stability.stable-diffusion-xl\") is False\n+\n+    def test_embedding_no_tools(self):\n+        from agent.bedrock_adapter import _model_supports_tool_use\n+        assert _model_supports_tool_use(\"cohere.embed-v4\") is False\n+\n+    def test_unknown_model_defaults_to_true(self):\n+        from agent.bedrock_adapter import _model_supports_tool_use\n+        assert _model_supports_tool_use(\"some-future-model-v1\") is True\n+\n+\n+class TestBuildConverseKwargsToolStripping:\n+    \"\"\"Test that tools are stripped for non-tool-calling models.\"\"\"\n+\n+    def test_tools_included_for_claude(self):\n+        from agent.bedrock_adapter import build_converse_kwargs\n+        tools = [{\"type\": \"function\", \"function\": {\"name\": \"test\", \"description\": \"t\", \"parameters\": {}}}]\n+        kwargs = build_converse_kwargs(\n+            model=\"us.anthropic.claude-sonnet-4-6\",\n+            messages=[{\"role\": \"user\", \"content\": \"Hi\"}],\n+            tools=tools,\n+        )\n+        assert \"toolConfig\" in kwargs\n+\n+    def test_tools_stripped_for_deepseek_r1(self):\n+        from agent.bedrock_adapter import build_converse_kwargs\n+        tools = [{\"type\": \"function\", \"function\": {\"name\": \"test\", \"description\": \"t\", \"parameters\": {}}}]\n+        kwargs = build_converse_kwargs(\n+            model=\"us.deepseek.r1-v1:0\",\n+            messages=[{\"role\": \"user\", \"content\": \"Hi\"}],\n+            tools=tools,\n+        )\n+        assert \"toolConfig\" not in kwargs\n+\n+\n+# ---------------------------------------------------------------------------\n+# Dual-path model routing\n+# ---------------------------------------------------------------------------\n+\n+class TestIsAnthropicBedrockModel:\n+    \"\"\"Test Claude model detection for dual-path routing.\"\"\"\n+\n+    def test_us_claude_sonnet(self):\n+        from agent.bedrock_adapter import is_anthropic_bedrock_model\n+        assert is_anthropic_bedrock_model(\"us.anthropic.claude-sonnet-4-6\") is True\n+\n+    def test_global_claude_opus(self):\n+        from agent.bedrock_adapter import is_anthropic_bedrock_model\n+        assert is_anthropic_bedrock_model(\"global.anthropic.claude-opus-4-6-v1\") is True\n+\n+    def test_bare_claude(self):\n+        from agent.bedrock_adapter import is_anthropic_bedrock_model\n+        assert is_anthropic_bedrock_model(\"anthropic.claude-haiku-4-5-20251001-v1:0\") is True\n+\n+    def test_nova_is_not_anthropic(self):\n+        from agent.bedrock_adapter import is_anthropic_bedrock_model\n+        assert is_anthropic_bedrock_model(\"us.amazon.nova-pro-v1:0\") is False\n+\n+    def test_deepseek_is_not_anthropic(self):\n+        from agent.bedrock_adapter import is_anthropic_bedrock_model\n+        assert is_anthropic_bedrock_model(\"deepseek.v3.2\") is False\n+\n+    def test_llama_is_not_anthropic(self):\n+        from agent.bedrock_adapter import is_anthropic_bedrock_model\n+        assert is_anthropic_bedrock_model(\"us.meta.llama4-scout-17b-instruct-v1:0\") is False\n+\n+    def test_mistral_is_not_anthropic(self):\n+        from agent.bedrock_adapter import is_anthropic_bedrock_model\n+        assert is_anthropic_bedrock_model(\"mistral.mistral-large-3-675b-instruct\") is False\n+\n+    def test_eu_claude(self):\n+        from agent.bedrock_adapter import is_anthropic_bedrock_model\n+        assert is_anthropic_bedrock_model(\"eu.anthropic.claude-sonnet-4-6\") is True\n+\n+\n+class TestEmptyTextBlockFix:\n+    \"\"\"Test that empty text blocks are replaced with space placeholders.\"\"\"\n+\n+    def test_none_content_gets_space(self):\n+        from agent.bedrock_adapter import _convert_content_to_converse\n+        blocks = _convert_content_to_converse(None)\n+        assert blocks[0][\"text\"] == \" \"\n+\n+    def test_empty_string_gets_space(self):\n+        from agent.bedrock_adapter import _convert_content_to_converse\n+        blocks = _convert_content_to_converse(\"\")\n+        assert blocks[0][\"text\"] == \" \"\n+\n+    def test_whitespace_only_gets_space(self):\n+        from agent.bedrock_adapter import _convert_content_to_converse\n+        blocks = _convert_content_to_converse(\"   \")\n+        assert blocks[0][\"text\"] == \" \"\n+\n+    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\""]},{"file":"tests/agent/test_bedrock_integration.py","hunks":["@@ -0,0 +1,269 @@\n+\"\"\"Integration tests for the AWS Bedrock provider wiring.\n+\n+Verifies that the Bedrock provider is correctly registered in the\n+provider registry, model catalog, and runtime resolution pipeline.\n+These tests do NOT require AWS credentials or boto3 — all AWS calls\n+are mocked.\n+\n+Note: Tests that import ``hermes_cli.auth`` or ``hermes_cli.runtime_provider``\n+require Python 3.10+ due to ``str | None`` type syntax in the import chain.\n+\"\"\"\n+\n+import os\n+from unittest.mock import MagicMock, patch\n+\n+import pytest\n+\n+\n+class TestProviderRegistry:\n+    \"\"\"Verify Bedrock is registered in PROVIDER_REGISTRY.\"\"\"\n+\n+    def test_bedrock_in_registry(self):\n+        from hermes_cli.auth import PROVIDER_REGISTRY\n+        assert \"bedrock\" in PROVIDER_REGISTRY\n+\n+    def test_bedrock_auth_type_is_aws_sdk(self):\n+        from hermes_cli.auth import PROVIDER_REGISTRY\n+        pconfig = PROVIDER_REGISTRY[\"bedrock\"]\n+        assert pconfig.auth_type == \"aws_sdk\"\n+\n+    def test_bedrock_has_no_api_key_env_vars(self):\n+        \"\"\"Bedrock uses the AWS SDK credential chain, not API keys.\"\"\"\n+        from hermes_cli.auth import PROVIDER_REGISTRY\n+        pconfig = PROVIDER_REGISTRY[\"bedrock\"]\n+        assert pconfig.api_key_env_vars == ()\n+\n+    def test_bedrock_base_url_env_var(self):\n+        from hermes_cli.auth import PROVIDER_REGISTRY\n+        pconfig = PROVIDER_REGISTRY[\"bedrock\"]\n+        assert pconfig.base_url_env_var == \"BEDROCK_BASE_URL\"\n+\n+\n+class TestProviderAliases:\n+    \"\"\"Verify Bedrock aliases resolve correctly.\"\"\"\n+\n+    def test_aws_alias(self):\n+        from hermes_cli.models import _PROVIDER_ALIASES\n+        assert _PROVIDER_ALIASES.get(\"aws\") == \"bedrock\"\n+\n+    def test_aws_bedrock_alias(self):\n+        from hermes_cli.models import _PROVIDER_ALIASES\n+        assert _PROVIDER_ALIASES.get(\"aws-bedrock\") == \"bedrock\"\n+\n+    def test_amazon_bedrock_alias(self):\n+        from hermes_cli.models import _PROVIDER_ALIASES\n+        assert _PROVIDER_ALIASES.get(\"amazon-bedrock\") == \"bedrock\"\n+\n+    def test_amazon_alias(self):\n+        from hermes_cli.models import _PROVIDER_ALIASES\n+        assert _PROVIDER_ALIASES.get(\"amazon\") == \"bedrock\"\n+\n+\n+class TestProviderLabels:\n+    \"\"\"Verify Bedrock appears in provider labels.\"\"\"\n+\n+    def test_bedrock_label(self):\n+        from hermes_cli.models import _PROVIDER_LABELS\n+        assert _PROVIDER_LABELS.get(\"bedrock\") == \"AWS Bedrock\"\n+\n+\n+class TestModelCatalog:\n+    \"\"\"Verify Bedrock has a static model fallback list.\"\"\"\n+\n+    def test_bedrock_has_curated_models(self):\n+        from hermes_cli.models import _PROVIDER_MODELS\n+        models = _PROVIDER_MODELS.get(\"bedrock\", [])\n+        assert len(models) > 0\n+\n+    def test_bedrock_models_include_claude(self):\n+        from hermes_cli.models import _PROVIDER_MODELS\n+        models = _PROVIDER_MODELS.get(\"bedrock\", [])\n+        claude_models = [m for m in models if \"anthropic.claude\" in m]\n+        assert len(claude_models) > 0\n+\n+    def test_bedrock_models_include_nova(self):\n+        from hermes_cli.models import _PROVIDER_MODELS\n+        models = _PROVIDER_MODELS.get(\"bedrock\", [])\n+        nova_models = [m for m in models if \"amazon.nova\" in m]\n+        assert len(nova_models) > 0\n+\n+\n+class TestResolveProvider:\n+    \"\"\"Verify resolve_provider() handles bedrock correctly.\"\"\"\n+\n+    def test_explicit_bedrock_resolves(self, monkeypatch):\n+        \"\"\"When user explicitly requests 'bedrock', it should resolve.\"\"\"\n+        from hermes_cli.auth import PROVIDER_REGISTRY\n+        # bedrock is in the registry, so resolve_provider should return it\n+        from hermes_cli.auth import resolve_provider\n+        result = resolve_provider(\"bedrock\")\n+        assert result == \"bedrock\"\n+\n+    def test_aws_alias_resolves_to_bedrock(self):\n+        from hermes_cli.auth import resolve_provider\n+        result = resolve_provider(\"aws\")\n+        assert result == \"bedrock\"\n+\n+    def test_amazon_bedrock_alias_resolves(self):\n+        from hermes_cli.auth import resolve_provider\n+        result = resolve_provider(\"amazon-bedrock\")\n+        assert result == \"bedrock\"\n+\n+    def test_auto_detect_with_aws_credentials(self, monkeypatch):\n+        \"\"\"When AWS credentials are present and no other provider is configured,\n+        auto-detect should find bedrock.\"\"\"\n+        from hermes_cli.auth import resolve_provider\n+\n+        # Clear all other provider env vars\n+        for var in [\"OPENAI_API_KEY\", \"OPENROUTER_API_KEY\", \"ANTHROPIC_API_KEY\",\n+                     \"ANTHROPIC_TOKEN\", \"GOOGLE_API_KEY\", \"DEEPSEEK_API_KEY\"]:\n+            monkeypatch.delenv(var, raising=False)\n+\n+        # Set AWS credentials\n+        monkeypatch.setenv(\"AWS_ACCESS_KEY_ID\", \"[redacted:aws-access-key]\")\n+        monkeypatch.setenv(\"AWS_SECRET_ACCESS_KEY\", \"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\")\n+\n+        # Mock the auth store to have no active provider\n+        with patch(\"hermes_cli.auth._load_auth_store\", return_value={}):\n+            result = resolve_provider(\"auto\")\n+        assert result == \"bedrock\"\n+\n+\n+class TestRuntimeProvider:\n+    \"\"\"Verify resolve_runtime_provider() handles bedrock correctly.\"\"\"\n+\n+    def test_bedrock_runtime_resolution(self, monkeypatch):\n+        from hermes_cli.runtime_provider import resolve_runtime_provider\n+\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-west-1\")\n+\n+        # Mock resolve_provider to return bedrock\n+        with patch(\"hermes_cli.runtime_provider.resolve_provider\", return_value=\"bedrock\"), \\\n+             patch(\"hermes_cli.runtime_provider._get_model_config\", return_value={\"provider\": \"bedrock\"}):\n+            result = resolve_runtime_provider(requested=\"bedrock\")\n+\n+        assert result[\"provider\"] == \"bedrock\"\n+        assert result[\"api_mode\"] == \"bedrock_converse\"\n+        assert result[\"region\"] == \"eu-west-1\"\n+        assert \"bedrock-runtime.eu-west-1.amazonaws.com\" in result[\"base_url\"]\n+        assert result[\"api_key\"] == \"aws-sdk\"\n+\n+    def test_bedrock_runtime_default_region(self, monkeypatch):\n+        from hermes_cli.runtime_provider import resolve_runtime_provider\n+\n+        monkeypatch.setenv(\"AWS_PROFILE\", \"default\")\n+        monkeypatch.delenv(\"AWS_REGION\", raising=False)\n+        monkeypatch.delenv(\"AWS_DEFAULT_REGION\", raising=False)\n+\n+        with patch(\"hermes_cli.runtime_provider.resolve_provider\", return_value=\"bedrock\"), \\\n+             patch(\"hermes_cli.runtime_provider._get_model_config\", return_value={\"provider\": \"bedrock\"}):\n+            result = resolve_runtime_provider(requested=\"bedrock\")\n+\n+        assert result[\"region\"] == \"us-east-1\"\n+\n+    def test_bedrock_runtime_no_credentials_raises_on_auto_detect(self, monkeypatch):\n+        \"\"\"When bedrock is auto-detected (not explicitly requested) and no\n+        credentials are found, runtime resolution should raise AuthError.\"\"\"\n+        from hermes_cli.runtime_provider import resolve_runtime_provider\n+        from hermes_cli.auth import AuthError\n+\n+        # Clear all AWS env vars\n+        for var in [\"AWS_ACCESS_KEY_ID\", \"AWS_SECRET_ACCESS_KEY\", \"AWS_PROFILE\",\n+                     \"AWS_BEARER_TOKEN_BEDROCK\", \"AWS_CONTAINER_CREDENTIALS_RELATIVE_URI\",\n+                     \"AWS_WEB_IDENTITY_TOKEN_FILE\"]:\n+            monkeypatch.delenv(var, raising=False)\n+\n+        # Mock both the provider resolution and boto3's credential chain\n+        mock_session = MagicMock()\n+        mock_session.get_credentials.return_value = None\n+        with patch(\"hermes_cli.runtime_provider.resolve_provider\", return_value=\"bedrock\"), \\\n+             patch(\"hermes_cli.runtime_provider._get_model_config\", return_value={\"provider\": \"bedrock\"}), \\\n+             patch(\"hermes_cli.runtime_provider.resolve_requested_provider\", return_value=\"auto\"), \\\n+             patch.dict(\"sys.modules\", {\"botocore\": MagicMock(), \"botocore.session\": MagicMock()}):\n+            import botocore.session as _bs\n+            _bs.get_session = MagicMock(return_value=mock_session)\n+            with pytest.raises(AuthError, match=\"No AWS credentials\"):\n+                resolve_runtime_provider(requested=\"auto\")\n+\n+    def test_bedrock_runtime_explicit_skips_credential_check(self, monkeypatch):\n+        \"\"\"When user explicitly requests bedrock, trust boto3's credential chain\n+        even if env-var detection finds nothing (covers IMDS, SSO, etc.).\"\"\"\n+        from hermes_cli.runtime_provider import resolve_runtime_provider\n+\n+        # No AWS env vars set — but explicit bedrock request should not raise\n+        for var in [\"AWS_ACCESS_KEY_ID\", \"AWS_SECRET_ACCESS_KEY\", \"AWS_PROFILE\",\n+                     \"AWS_BEARER_TOKEN_BEDROCK\"]:\n+            monkeypatch.delenv(var, raising=False)\n+\n+        with patch(\"hermes_cli.runtime_provider.resolve_provider\", return_value=\"bedrock\"), \\\n+             patch(\"hermes_cli.runtime_provider._get_model_config\", return_value={\"provider\": \"bedrock\"}):\n+            result = resolve_runtime_provider(requested=\"bedrock\")\n+        assert result[\"provider\"] == \"bedrock\"\n+        assert result[\"api_mode\"] == \"bedrock_converse\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# providers.py integration\n+# ---------------------------------------------------------------------------\n+\n+class TestProvidersModule:\n+    \"\"\"Verify bedrock is wired into hermes_cli/providers.py.\"\"\"\n+\n+    def test_bedrock_alias_in_providers(self):\n+        from hermes_cli.providers import ALIASES\n+        assert ALIASES.get(\"bedrock\") is None  # \"bedrock\" IS the canonical name, not an alias\n+        assert ALIASES.get(\"aws\") == \"bedrock\"\n+        assert ALIASES.get(\"aws-bedrock\") == \"bedrock\"\n+\n+    def test_bedrock_transport_mapping(self):\n+        from hermes_cli.providers import TRANSPORT_TO_API_MODE\n+        assert TRANSPORT_TO_API_MODE.get(\"bedrock_converse\") == \"bedrock_converse\"\n+\n+    def test_determine_api_mode_from_bedrock_url(self):\n+        from hermes_cli.providers import determine_api_mode\n+        assert determine_api_mode(\n+            \"unknown\", \"https://bedrock-runtime.us-east-1.amazonaws.com\"\n+        ) == \"bedrock_converse\"\n+\n+    def test_label_override(self):\n+        from hermes_cli.providers import _LABEL_OVERRIDES\n+        assert _LABEL_OVERRIDES.get(\"bedrock\") == \"AWS Bedrock\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Error classifier integration\n+# ---------------------------------------------------------------------------\n+\n+class TestErrorClassifierBedrock:\n+    \"\"\"Verify Bedrock error patterns are in the global error classifier.\"\"\"\n+\n+    def test_throttling_in_rate_limit_patterns(self):\n+        from agent.error_classifier import _RATE_LIMIT_PATTERNS\n+        assert \"throttlingexception\" in _RATE_LIMIT_PATTERNS\n+\n+    def test_context_overflow_patterns(self):\n+        from agent.error_classifier import _CONTEXT_OVERFLOW_PATTERNS\n+        assert \"input is too long\" in _CONTEXT_OVERFLOW_PATTERNS\n+\n+\n+# ---------------------------------------------------------------------------\n+# pyproject.toml bedrock extra\n+# ---------------------------------------------------------------------------\n+\n+class TestPackaging:\n+    \"\"\"Verify bedrock optional dependency is declared.\"\"\"\n+\n+    def test_bedrock_extra_exists(self):\n+        import configparser\n+        from pathlib import Path\n+        # Read pyproject.toml to verify [bedrock] extra\n+        toml_path = Path(__file__).parent.parent.parent / \"pyproject.toml\"\n+        content = toml_path.read_text()\n+        assert 'bedrock = [\"boto3' in content\n+\n+    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"]},{"file":"website/docs/guides/aws-bedrock.md","hunks":["@@ -0,0 +1,164 @@\n+---\n+sidebar_position: 14\n+title: \"AWS Bedrock\"\n+description: \"Use Hermes Agent with Amazon Bedrock — native Converse API, IAM authentication, Guardrails, and cross-region inference\"\n+---\n+\n+# AWS Bedrock\n+\n+Hermes Agent supports Amazon Bedrock as a native provider using the **Converse API** — not the OpenAI-compatible endpoint. This gives you full access to the Bedrock ecosystem: IAM authentication, Guardrails, cross-region inference profiles, and all foundation models.\n+\n+## Prerequisites\n+\n+- **AWS credentials** — any source supported by the [boto3 credential chain](https://boto3.amazonaws.com/v1/documentation/api/latest/guide/credentials.html):\n+  - IAM instance role (EC2, ECS, Lambda — zero config)\n+  - `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` environment variables\n+  - `AWS_PROFILE` for SSO or named profiles\n+  - `aws configure` for local development\n+- **boto3** — install with `pip install hermes-agent[bedrock]`\n+- **IAM permissions** — at minimum:\n+  - `bedrock:InvokeModel` and `bedrock:InvokeModelWithResponseStream` (for inference)\n+  - `bedrock:ListFoundationModels` and `bedrock:ListInferenceProfiles` (for model discovery)\n+\n+:::tip EC2 / ECS / Lambda\n+On AWS compute, attach an IAM role with `AmazonBedrockFullAccess` and you're done. No API keys, no `.env` configuration — Hermes detects the instance role automatically.\n+:::\n+\n+## Quick Start\n+\n+```bash\n+# Install with Bedrock support\n+pip install hermes-agent[bedrock]\n+\n+# Select Bedrock as your provider\n+hermes model\n+# → Choose \"More providers...\" → \"AWS Bedrock\"\n+# → Select your region and model\n+\n+# Start chatting\n+hermes chat\n+```\n+\n+## Configuration\n+\n+After running `hermes model`, your `~/.hermes/config.yaml` will contain:\n+\n+```yaml\n+model:\n+  default: us.anthropic.claude-sonnet-4-6\n+  provider: bedrock\n+  base_url: https://bedrock-runtime.us-east-2.amazonaws.com\n+\n+bedrock:\n+  region: us-east-2\n+```\n+\n+### Region\n+\n+Set the AWS region in any of these ways (highest priority first):\n+\n+1. `bedrock.region` in `config.yaml`\n+2. `AWS_REGION` environment variable\n+3. `AWS_DEFAULT_REGION` environment variable\n+4. Default: `us-east-1`\n+\n+### Guardrails\n+\n+To apply [Amazon Bedrock Guardrails](https://docs.aws.amazon.com/bedrock/latest/userguide/guardrails.html) to all model invocations:\n+\n+```yaml\n+bedrock:\n+  region: us-east-2\n+  guardrail:\n+    guardrail_identifier: \"abc123def456\"  # From the Bedrock console\n+    guardrail_version: \"1\"                # Version number or \"DRAFT\"\n+    stream_processing_mode: \"async\"       # \"sync\" or \"async\"\n+    trace: \"disabled\"                     # \"enabled\", \"disabled\", or \"enabled_full\"\n+```\n+\n+### Model Discovery\n+\n+Hermes auto-discovers available models via the Bedrock control plane. You can customize discovery:\n+\n+```yaml\n+bedrock:\n+  discovery:\n+    enabled: true\n+    provider_filter: [\"anthropic\", \"amazon\"]  # Only show these providers\n+    refresh_interval: 3600                     # Cache for 1 hour\n+```\n+\n+## Available Models\n+\n+Bedrock models use **inference profile IDs** for on-demand invocation. The `hermes model` picker shows these automatically, with recommended models at the top:\n+\n+| Model | ID | Notes |\n+|-------|-----|-------|\n+| Claude Sonnet 4.6 | `us.anthropic.claude-sonnet-4-6` | Recommended — best balance of speed and capability |\n+| Claude Opus 4.6 | `us.anthropic.claude-opus-4-6-v1` | Most capable |\n+| Claude Haiku 4.5 | `us.anthropic.claude-haiku-4-5-20251001-v1:0` | Fastest Claude |\n+| Amazon Nova Pro | `us.amazon.nova-pro-v1:0` | Amazon's flagship |\n+| Amazon Nova Micro | `us.amazon.nova-micro-v1:0` | Fastest, cheapest |\n+| DeepSeek V3.2 | `deepseek.v3.2` | Strong open model |\n+| Llama 4 Scout 17B | `us.meta.llama4-scout-17b-instruct-v1:0` | Meta's latest |\n+\n+:::info Cross-Region Inference\n+Models prefixed with `us.` use cross-region inference profiles, which provide better capacity and automatic failover across AWS regions. Models prefixed with `global.` route across all available regions worldwide.\n+:::\n+\n+## Switching Models Mid-Session\n+\n+Use the `/model` command during a conversation:\n+\n+```\n+/model us.amazon.nova-pro-v1:0\n+/model deepseek.v3.2\n+/model us.anthropic.claude-opus-4-6-v1\n+```\n+\n+## Diagnostics\n+\n+```bash\n+hermes doctor\n+```\n+\n+The doctor checks:\n+- Whether AWS credentials are available (env vars, IAM role, SSO)\n+- Whether `boto3` is installed\n+- Whether the Bedrock API is reachable (ListFoundationModels)\n+- Number of available models in your region\n+\n+## Gateway (Messaging Platforms)\n+\n+Bedrock works with all Hermes gateway platforms (Telegram, Discord, Slack, Feishu, etc.). Configure Bedrock as your provider, then start the gateway normally:\n+\n+```bash\n+hermes gateway setup\n+hermes gateway start\n+```\n+\n+The gateway reads `config.yaml` and uses the same Bedrock provider configuration.\n+\n+## Troubleshooting\n+\n+### \"No API key found\" / \"No AWS credentials\"\n+\n+Hermes checks for credentials in this order:\n+1. `AWS_BEARER_TOKEN_BEDROCK`\n+2. `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY`\n+3. `AWS_PROFILE`\n+4. EC2 instance metadata (IMDS)\n+5. ECS container credentials\n+6. Lambda execution role\n+\n+If none are found, run `aws configure` or attach an IAM role to your compute instance.\n+\n+### \"Invocation of model ID ... with on-demand throughput isn't supported\"\n+\n+Use an **inference profile ID** (prefixed with `us.` or `global.`) instead of the bare foundation model ID. For example:\n+- ❌ `anthropic.claude-sonnet-4-6`\n+- ✅ `us.anthropic.claude-sonnet-4-6`\n+\n+### \"ThrottlingException\"\n+\n+You've hit the Bedrock per-model rate limit. Hermes automatically retries with backoff. To increase limits, request a quota increase in the [AWS Service Quotas console](https://console.aws.amazon.com/servicequotas/).\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}