diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 4f1746166256..a9d1d9159904 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -103,6 +103,7 @@ def _normalize_aux_provider(provider: Optional[str]) -> str: "minimax": "MiniMax-M2.7", "minimax-cn": "MiniMax-M2.7", "anthropic": "claude-haiku-4-5-20251001", + "bedrock": "us.anthropic.claude-haiku-4-5-20251001-v1:0", "ai-gateway": "google/gemini-3-flash", "opencode-zen": "gemini-3-flash", "opencode-go": "glm-5", @@ -484,10 +485,17 @@ def __init__(self, sync_wrapper: "CodexAuxiliaryClient"): class _AnthropicCompletionsAdapter: """OpenAI-client-compatible adapter for Anthropic Messages API.""" - def __init__(self, real_client: Any, model: str, is_oauth: bool = False): + def __init__( + self, + real_client: Any, + model: str, + is_oauth: bool = False, + preserve_dots: bool = False, + ): self._client = real_client self._model = model self._is_oauth = is_oauth + self._preserve_dots = preserve_dots def create(self, **kwargs) -> Any: from agent.anthropic_adapter import build_anthropic_kwargs, normalize_anthropic_response @@ -517,6 +525,7 @@ def create(self, **kwargs) -> Any: reasoning_config=None, tool_choice=normalized_tool_choice, is_oauth=self._is_oauth, + preserve_dots=self._preserve_dots, ) # Opus 4.7+ rejects any non-default temperature/top_p/top_k; only set # temperature for models that still accept it. build_anthropic_kwargs @@ -558,11 +567,25 @@ def __init__(self, adapter: _AnthropicCompletionsAdapter): class AnthropicAuxiliaryClient: - """OpenAI-client-compatible wrapper over a native Anthropic client.""" + """OpenAI-client-compatible wrapper over a native Anthropic-style client. - def __init__(self, real_client: Any, model: str, api_key: str, base_url: str, is_oauth: bool = False): + Accepts either ``anthropic.Anthropic`` or ``anthropic.AnthropicBedrock``; + both expose the same ``messages.create()`` surface. + """ + + def __init__( + self, + real_client: Any, + model: str, + api_key: str, + base_url: str, + is_oauth: bool = False, + preserve_dots: bool = False, + ): self._real_client = real_client - adapter = _AnthropicCompletionsAdapter(real_client, model, is_oauth=is_oauth) + adapter = _AnthropicCompletionsAdapter( + real_client, model, is_oauth=is_oauth, preserve_dots=preserve_dots, + ) self.chat = _AnthropicChatShim(adapter) self.api_key = api_key self.base_url = base_url @@ -1056,6 +1079,52 @@ def _try_anthropic() -> Tuple[Optional[Any], Optional[str]]: return AnthropicAuxiliaryClient(real_client, model, token, base_url, is_oauth=is_oauth), model +def _try_bedrock() -> Tuple[Optional[Any], Optional[str]]: + """Build an auxiliary client backed by AnthropicBedrock + boto3.""" + try: + from agent.anthropic_adapter import build_anthropic_bedrock_client + from agent.bedrock_adapter import has_aws_credentials, resolve_bedrock_region + except ImportError: + return None, None + + if not has_aws_credentials(): + logger.debug("Auxiliary client: Bedrock requested but no AWS credentials found") + return None, None + + # config.yaml bedrock.region wins over env, mirroring the inference path + # in hermes_cli/runtime_provider.py → resolve_bedrock_runtime. + region = "" + try: + from hermes_cli.config import load_config + region = str((load_config().get("bedrock") or {}).get("region") or "").strip() + except Exception: + pass + region = region or resolve_bedrock_region() + + model = _API_KEY_PROVIDER_AUX_MODELS["bedrock"] + logger.debug("Auxiliary client: Bedrock (%s) region=%s", model, region) + + try: + real_client = build_anthropic_bedrock_client(region) + except ImportError: + # anthropic SDK missing AnthropicBedrock (older version). + return None, None + except Exception as exc: + logger.debug("Auxiliary client: Bedrock init failed: %s", exc) + return None, None + + return ( + AnthropicAuxiliaryClient( + real_client, + model, + api_key="aws-sdk", + base_url=f"https://bedrock-runtime.{region}.amazonaws.com", + preserve_dots=True, + ), + model, + ) + + _AUTO_PROVIDER_LABELS = { "_try_openrouter": "openrouter", "_try_nous": "nous", @@ -1558,6 +1627,17 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = ""): logger.warning("resolve_provider_client: unknown provider %r", provider) return None, None + if pconfig.auth_type == "aws_sdk": + client, default_model = _try_bedrock() + if client is None: + logger.warning( + "resolve_provider_client: bedrock requested but AWS credentials " + "or boto3/anthropic SDK are unavailable" + ) + return None, None + final_model = _normalize_resolved_model(model or default_model, provider) + return (_to_async_client(client, final_model) if async_mode else (client, final_model)) + if pconfig.auth_type == "api_key": if provider == "anthropic": client, default_model = _try_anthropic() diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 5d79f96deaf7..93b8b8cb3223 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -447,6 +447,63 @@ def test_explicit_anthropic_api_key(self, monkeypatch): adapter = client.chat.completions assert adapter._is_oauth is False + +class TestBedrockAuxiliary: + """Bedrock auxiliary client — uses AnthropicBedrock + boto3, exposes the + same .chat.completions.create() shape as direct Anthropic via the + AnthropicAuxiliaryClient wrapper.""" + + @pytest.mark.parametrize( + "cfg,expected_region", + [ + ({}, "us-east-1"), + ({"bedrock": {"region": "eu-central-1"}}, "eu-central-1"), + ], + ) + def test_try_bedrock_builds_wrapped_client(self, cfg, expected_region): + """Happy path: AWS creds present → build AnthropicBedrock, wrap with + AnthropicAuxiliaryClient, preserve_dots=True so the Haiku 4.5 + inference profile ID survives normalization. config.yaml + bedrock.region wins over resolve_bedrock_region() fallback.""" + with ( + patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), + patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="us-east-1"), + patch("agent.anthropic_adapter.build_anthropic_bedrock_client") as mock_build, + patch("hermes_cli.config.load_config", return_value=cfg), + ): + mock_build.return_value = MagicMock() + from agent.auxiliary_client import _try_bedrock, AnthropicAuxiliaryClient + client, model = _try_bedrock() + assert isinstance(client, AnthropicAuxiliaryClient) + assert model == "us.anthropic.claude-haiku-4-5-20251001-v1:0" + assert client.api_key == "aws-sdk" + assert f"bedrock-runtime.{expected_region}.amazonaws.com" in client.base_url + assert client.chat.completions._preserve_dots is True + mock_build.assert_called_once_with(expected_region) + + def test_resolve_provider_client_bedrock_happy_path(self): + """provider='bedrock' routes through the aws_sdk branch and returns + an AnthropicAuxiliaryClient — no silent fall-through to auto-detect.""" + with ( + patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), + patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"), + patch("agent.anthropic_adapter.build_anthropic_bedrock_client") as mock_build, + patch("hermes_cli.config.load_config", return_value={}), + ): + mock_build.return_value = MagicMock() + from agent.auxiliary_client import AnthropicAuxiliaryClient + client, model = resolve_provider_client("bedrock") + assert isinstance(client, AnthropicAuxiliaryClient) + assert "haiku" in model.lower() + + def test_resolve_provider_client_bedrock_missing_credentials(self): + """No AWS creds → (None, None), caller can fall back explicitly.""" + with patch("agent.bedrock_adapter.has_aws_credentials", return_value=False): + client, model = resolve_provider_client("bedrock") + assert client is None + assert model is None + + class TestGetTextAuxiliaryClient: """Test the full resolution chain for get_text_auxiliary_client."""