Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 84 additions & 4 deletions agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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()
Expand Down
57 changes: 57 additions & 0 deletions tests/agent/test_auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down