| """Integration tests for the AWS Bedrock provider wiring. |
| |
| Verifies that the Bedrock provider is correctly registered in the |
| provider registry, model catalog, and runtime resolution pipeline. |
| These tests do NOT require AWS credentials or boto3 — all AWS calls |
| are mocked. |
| |
| Note: Tests that import ``hermes_cli.auth`` or ``hermes_cli.runtime_provider`` |
| require Python 3.10+ due to ``str | None`` type syntax in the import chain. |
| """ |
|
|
| import os |
| from unittest.mock import MagicMock, patch |
|
|
| import pytest |
|
|
|
|
| class TestProviderRegistry: |
| """Verify Bedrock is registered in PROVIDER_REGISTRY.""" |
|
|
| def test_bedrock_in_registry(self): |
| from hermes_cli.auth import PROVIDER_REGISTRY |
| assert "bedrock" in PROVIDER_REGISTRY |
|
|
| def test_bedrock_auth_type_is_aws_sdk(self): |
| from hermes_cli.auth import PROVIDER_REGISTRY |
| pconfig = PROVIDER_REGISTRY["bedrock"] |
| assert pconfig.auth_type == "aws_sdk" |
|
|
| def test_bedrock_has_no_api_key_env_vars(self): |
| """Bedrock uses the AWS SDK credential chain, not API keys.""" |
| from hermes_cli.auth import PROVIDER_REGISTRY |
| pconfig = PROVIDER_REGISTRY["bedrock"] |
| assert pconfig.api_key_env_vars == () |
|
|
| def test_bedrock_base_url_env_var(self): |
| from hermes_cli.auth import PROVIDER_REGISTRY |
| pconfig = PROVIDER_REGISTRY["bedrock"] |
| assert pconfig.base_url_env_var == "BEDROCK_BASE_URL" |
|
|
|
|
| class TestProviderAliases: |
| """Verify Bedrock aliases resolve correctly.""" |
|
|
| def test_aws_alias(self): |
| from hermes_cli.models import _PROVIDER_ALIASES |
| assert _PROVIDER_ALIASES.get("aws") == "bedrock" |
|
|
| def test_aws_bedrock_alias(self): |
| from hermes_cli.models import _PROVIDER_ALIASES |
| assert _PROVIDER_ALIASES.get("aws-bedrock") == "bedrock" |
|
|
| def test_amazon_bedrock_alias(self): |
| from hermes_cli.models import _PROVIDER_ALIASES |
| assert _PROVIDER_ALIASES.get("amazon-bedrock") == "bedrock" |
|
|
| def test_amazon_alias(self): |
| from hermes_cli.models import _PROVIDER_ALIASES |
| assert _PROVIDER_ALIASES.get("amazon") == "bedrock" |
|
|
|
|
| class TestProviderLabels: |
| """Verify Bedrock appears in provider labels.""" |
|
|
| def test_bedrock_label(self): |
| from hermes_cli.models import _PROVIDER_LABELS |
| assert _PROVIDER_LABELS.get("bedrock") == "AWS Bedrock" |
|
|
|
|
| class TestModelCatalog: |
| """Verify Bedrock has a static model fallback list.""" |
|
|
| def test_bedrock_has_curated_models(self): |
| from hermes_cli.models import _PROVIDER_MODELS |
| models = _PROVIDER_MODELS.get("bedrock", []) |
| assert len(models) > 0 |
|
|
| def test_bedrock_models_include_claude(self): |
| from hermes_cli.models import _PROVIDER_MODELS |
| models = _PROVIDER_MODELS.get("bedrock", []) |
| claude_models = [m for m in models if "anthropic.claude" in m] |
| assert len(claude_models) > 0 |
|
|
| def test_bedrock_models_include_nova(self): |
| from hermes_cli.models import _PROVIDER_MODELS |
| models = _PROVIDER_MODELS.get("bedrock", []) |
| nova_models = [m for m in models if "amazon.nova" in m] |
| assert len(nova_models) > 0 |
|
|
|
|
| class TestResolveProvider: |
| """Verify resolve_provider() handles bedrock correctly.""" |
|
|
| def test_explicit_bedrock_resolves(self, monkeypatch): |
| """When user explicitly requests 'bedrock', it should resolve.""" |
| from hermes_cli.auth import PROVIDER_REGISTRY |
| |
| from hermes_cli.auth import resolve_provider |
| result = resolve_provider("bedrock") |
| assert result == "bedrock" |
|
|
| def test_aws_alias_resolves_to_bedrock(self): |
| from hermes_cli.auth import resolve_provider |
| result = resolve_provider("aws") |
| assert result == "bedrock" |
|
|
| def test_amazon_bedrock_alias_resolves(self): |
| from hermes_cli.auth import resolve_provider |
| result = resolve_provider("amazon-bedrock") |
| assert result == "bedrock" |
|
|
| def test_auto_detect_with_aws_credentials(self, monkeypatch): |
| """When AWS credentials are present and no other provider is configured, |
| auto-detect should find bedrock.""" |
| from hermes_cli.auth import resolve_provider |
|
|
| |
| for var in ["OPENAI_API_KEY", "OPENROUTER_API_KEY", "ANTHROPIC_API_KEY", |
| "ANTHROPIC_TOKEN", "GOOGLE_API_KEY", "DEEPSEEK_API_KEY"]: |
| monkeypatch.delenv(var, raising=False) |
|
|
| |
| monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE") |
| monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY") |
|
|
| |
| with patch("hermes_cli.auth._load_auth_store", return_value={}): |
| result = resolve_provider("auto") |
| assert result == "bedrock" |
|
|
|
|
| class TestRuntimeProvider: |
| """Verify resolve_runtime_provider() handles bedrock correctly.""" |
|
|
| def test_bedrock_runtime_resolution(self, monkeypatch): |
| from hermes_cli.runtime_provider import resolve_runtime_provider |
|
|
| monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE") |
| monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY") |
| monkeypatch.setenv("AWS_REGION", "eu-west-1") |
|
|
| |
| with patch("hermes_cli.runtime_provider.resolve_provider", return_value="bedrock"), \ |
| patch("hermes_cli.runtime_provider._get_model_config", return_value={"provider": "bedrock"}): |
| result = resolve_runtime_provider(requested="bedrock") |
|
|
| assert result["provider"] == "bedrock" |
| assert result["api_mode"] == "bedrock_converse" |
| assert result["region"] == "eu-west-1" |
| assert "bedrock-runtime.eu-west-1.amazonaws.com" in result["base_url"] |
| assert result["api_key"] == "aws-sdk" |
|
|
| def test_bedrock_runtime_default_region(self, monkeypatch): |
| from hermes_cli.runtime_provider import resolve_runtime_provider |
|
|
| monkeypatch.setenv("AWS_PROFILE", "default") |
| monkeypatch.delenv("AWS_REGION", raising=False) |
| monkeypatch.delenv("AWS_DEFAULT_REGION", raising=False) |
|
|
| with patch("hermes_cli.runtime_provider.resolve_provider", return_value="bedrock"), \ |
| patch("hermes_cli.runtime_provider._get_model_config", return_value={"provider": "bedrock"}): |
| result = resolve_runtime_provider(requested="bedrock") |
|
|
| assert result["region"] == "us-east-1" |
|
|
| def test_bedrock_runtime_no_credentials_raises_on_auto_detect(self, monkeypatch): |
| """When bedrock is auto-detected (not explicitly requested) and no |
| credentials are found, runtime resolution should raise AuthError.""" |
| from hermes_cli.runtime_provider import resolve_runtime_provider |
| from hermes_cli.auth import AuthError |
|
|
| |
| for var in ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_PROFILE", |
| "AWS_BEARER_TOKEN_BEDROCK", "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", |
| "AWS_WEB_IDENTITY_TOKEN_FILE"]: |
| monkeypatch.delenv(var, raising=False) |
|
|
| |
| mock_session = MagicMock() |
| mock_session.get_credentials.return_value = None |
| with patch("hermes_cli.runtime_provider.resolve_provider", return_value="bedrock"), \ |
| patch("hermes_cli.runtime_provider._get_model_config", return_value={"provider": "bedrock"}), \ |
| patch("hermes_cli.runtime_provider.resolve_requested_provider", return_value="auto"), \ |
| patch.dict("sys.modules", {"botocore": MagicMock(), "botocore.session": MagicMock()}): |
| import botocore.session as _bs |
| _bs.get_session = MagicMock(return_value=mock_session) |
| with pytest.raises(AuthError, match="No AWS credentials"): |
| resolve_runtime_provider(requested="auto") |
|
|
| def test_bedrock_runtime_explicit_skips_credential_check(self, monkeypatch): |
| """When user explicitly requests bedrock, trust boto3's credential chain |
| even if env-var detection finds nothing (covers IMDS, SSO, etc.).""" |
| from hermes_cli.runtime_provider import resolve_runtime_provider |
|
|
| |
| for var in ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_PROFILE", |
| "AWS_BEARER_TOKEN_BEDROCK"]: |
| monkeypatch.delenv(var, raising=False) |
|
|
| with patch("hermes_cli.runtime_provider.resolve_provider", return_value="bedrock"), \ |
| patch("hermes_cli.runtime_provider._get_model_config", return_value={"provider": "bedrock"}): |
| result = resolve_runtime_provider(requested="bedrock") |
| assert result["provider"] == "bedrock" |
| assert result["api_mode"] == "bedrock_converse" |
|
|
|
|
| |
| |
| |
|
|
| class TestProvidersModule: |
| """Verify bedrock is wired into hermes_cli/providers.py.""" |
|
|
| def test_bedrock_alias_in_providers(self): |
| from hermes_cli.providers import ALIASES |
| assert ALIASES.get("bedrock") is None |
| assert ALIASES.get("aws") == "bedrock" |
| assert ALIASES.get("aws-bedrock") == "bedrock" |
|
|
| def test_bedrock_transport_mapping(self): |
| from hermes_cli.providers import TRANSPORT_TO_API_MODE |
| assert TRANSPORT_TO_API_MODE.get("bedrock_converse") == "bedrock_converse" |
|
|
| def test_determine_api_mode_from_bedrock_url(self): |
| from hermes_cli.providers import determine_api_mode |
| assert determine_api_mode( |
| "unknown", "https://bedrock-runtime.us-east-1.amazonaws.com" |
| ) == "bedrock_converse" |
|
|
| def test_label_override(self): |
| from hermes_cli.providers import _LABEL_OVERRIDES |
| assert _LABEL_OVERRIDES.get("bedrock") == "AWS Bedrock" |
|
|
|
|
| |
| |
| |
|
|
| class TestErrorClassifierBedrock: |
| """Verify Bedrock error patterns are in the global error classifier.""" |
|
|
| def test_throttling_in_rate_limit_patterns(self): |
| from agent.error_classifier import _RATE_LIMIT_PATTERNS |
| assert "throttlingexception" in _RATE_LIMIT_PATTERNS |
|
|
| def test_context_overflow_patterns(self): |
| from agent.error_classifier import _CONTEXT_OVERFLOW_PATTERNS |
| assert "input is too long" in _CONTEXT_OVERFLOW_PATTERNS |
|
|
|
|
| |
| |
| |
|
|
| class TestPackaging: |
| """Verify bedrock optional dependency is declared.""" |
|
|
| def test_bedrock_extra_exists(self): |
| import configparser |
| from pathlib import Path |
| |
| toml_path = Path(__file__).parent.parent.parent / "pyproject.toml" |
| content = toml_path.read_text() |
| assert 'bedrock = ["boto3' in content |
|
|
| def test_bedrock_in_all_extra(self): |
| from pathlib import Path |
| content = (Path(__file__).parent.parent.parent / "pyproject.toml").read_text() |
| assert '"hermes-agent[bedrock]"' in content |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| class TestBedrockPreserveDotsFlag: |
| """``AIAgent._anthropic_preserve_dots`` must return True on Bedrock so |
| inference-profile IDs survive the normalize step intact.""" |
|
|
| def test_bedrock_provider_preserves_dots(self): |
| from types import SimpleNamespace |
| agent = SimpleNamespace(provider="bedrock", base_url="") |
| from run_agent import AIAgent |
| assert AIAgent._anthropic_preserve_dots(agent) is True |
|
|
| def test_bedrock_runtime_us_east_1_url_preserves_dots(self): |
| """Defense-in-depth: even without an explicit ``provider="bedrock"``, |
| a ``bedrock-runtime.us-east-1.amazonaws.com`` base URL must not |
| mangle dots.""" |
| from types import SimpleNamespace |
| agent = SimpleNamespace( |
| provider="custom", |
| base_url="https://bedrock-runtime.us-east-1.amazonaws.com", |
| ) |
| from run_agent import AIAgent |
| assert AIAgent._anthropic_preserve_dots(agent) is True |
|
|
| def test_bedrock_runtime_ap_northeast_2_url_preserves_dots(self): |
| """Reporter-reported region (ap-northeast-2) exercises the same |
| base-URL heuristic.""" |
| from types import SimpleNamespace |
| agent = SimpleNamespace( |
| provider="custom", |
| base_url="https://bedrock-runtime.ap-northeast-2.amazonaws.com", |
| ) |
| from run_agent import AIAgent |
| assert AIAgent._anthropic_preserve_dots(agent) is True |
|
|
| def test_non_bedrock_aws_url_does_not_preserve_dots(self): |
| """Unrelated AWS endpoints (e.g. ``s3.us-east-1.amazonaws.com``) |
| must not accidentally activate the dot-preservation heuristic — |
| the heuristic is scoped to the ``bedrock-runtime.`` substring |
| specifically.""" |
| from types import SimpleNamespace |
| agent = SimpleNamespace( |
| provider="custom", |
| base_url="https://s3.us-east-1.amazonaws.com", |
| ) |
| from run_agent import AIAgent |
| assert AIAgent._anthropic_preserve_dots(agent) is False |
|
|
| def test_anthropic_native_still_does_not_preserve_dots(self): |
| """Canary: adding Bedrock to the allowlist must not weaken the |
| existing Anthropic native behaviour — ``claude-sonnet-4.6`` still |
| becomes ``claude-sonnet-4-6`` for the Anthropic API.""" |
| from types import SimpleNamespace |
| agent = SimpleNamespace(provider="anthropic", base_url="https://api.anthropic.com") |
| from run_agent import AIAgent |
| assert AIAgent._anthropic_preserve_dots(agent) is False |
|
|
|
|
| class TestBedrockModelNameNormalization: |
| """End-to-end: ``normalize_model_name`` + the preserve-dots flag |
| reproduce the exact production request shape for each Bedrock model |
| family, confirming the fix resolves the reporter's HTTP 400.""" |
|
|
| def test_global_anthropic_inference_profile_preserved(self): |
| """The reporter's exact model ID.""" |
| from agent.anthropic_adapter import normalize_model_name |
| assert normalize_model_name( |
| "global.anthropic.claude-opus-4-7", preserve_dots=True |
| ) == "global.anthropic.claude-opus-4-7" |
|
|
| def test_us_anthropic_dated_inference_profile_preserved(self): |
| """Regional + dated Sonnet inference profile.""" |
| from agent.anthropic_adapter import normalize_model_name |
| assert normalize_model_name( |
| "us.anthropic.claude-sonnet-4-5-20250929-v1:0", |
| preserve_dots=True, |
| ) == "us.anthropic.claude-sonnet-4-5-20250929-v1:0" |
|
|
| def test_apac_anthropic_haiku_inference_profile_preserved(self): |
| """APAC inference profile — same structural-dot shape.""" |
| from agent.anthropic_adapter import normalize_model_name |
| assert normalize_model_name( |
| "apac.anthropic.claude-haiku-4-5", preserve_dots=True |
| ) == "apac.anthropic.claude-haiku-4-5" |
|
|
| def test_preserve_false_mangles_as_documented(self): |
| """Canary: with ``preserve_dots=False`` the function still |
| produces the broken all-hyphen form — this is the shape that |
| Bedrock rejected and that the fix avoids. Keeping this test |
| locks in the existing behaviour of ``normalize_model_name`` so a |
| future refactor doesn't accidentally decouple the knob from its |
| effect.""" |
| from agent.anthropic_adapter import normalize_model_name |
| assert normalize_model_name( |
| "global.anthropic.claude-opus-4-7", preserve_dots=False |
| ) == "global-anthropic-claude-opus-4-7" |
|
|
| def test_bare_foundation_model_id_preserved(self): |
| """Non-inference-profile Bedrock IDs |
| (e.g. ``anthropic.claude-3-5-sonnet-20241022-v2:0``) use dots as |
| vendor separators and must also survive intact under |
| ``preserve_dots=True``.""" |
| from agent.anthropic_adapter import normalize_model_name |
| assert normalize_model_name( |
| "anthropic.claude-3-5-sonnet-20241022-v2:0", |
| preserve_dots=True, |
| ) == "anthropic.claude-3-5-sonnet-20241022-v2:0" |
|
|
|
|
| class TestBedrockBuildAnthropicKwargsEndToEnd: |
| """Integration: calling ``build_anthropic_kwargs`` with a Bedrock- |
| shaped model ID and ``preserve_dots=True`` produces the unmangled |
| model string in the outgoing kwargs — the exact body sent to the |
| ``bedrock-runtime.`` endpoint. This is the integration-level |
| regression for the reporter's HTTP 400.""" |
|
|
| def test_bedrock_inference_profile_survives_build_kwargs(self): |
| from agent.anthropic_adapter import build_anthropic_kwargs |
| kwargs = build_anthropic_kwargs( |
| model="global.anthropic.claude-opus-4-7", |
| messages=[{"role": "user", "content": "hi"}], |
| tools=None, |
| max_tokens=1024, |
| reasoning_config=None, |
| preserve_dots=True, |
| ) |
| assert kwargs["model"] == "global.anthropic.claude-opus-4-7", ( |
| "Bedrock inference-profile ID was mangled in build_anthropic_kwargs: " |
| f"{kwargs['model']!r}" |
| ) |
|
|
| def test_bedrock_model_mangled_without_preserve_dots(self): |
| """Inverse canary: without the flag, ``build_anthropic_kwargs`` |
| still produces the broken form — so the fix in |
| ``_anthropic_preserve_dots`` is the load-bearing piece that |
| wires ``preserve_dots=True`` through to this builder for the |
| Bedrock case.""" |
| from agent.anthropic_adapter import build_anthropic_kwargs |
| kwargs = build_anthropic_kwargs( |
| model="global.anthropic.claude-opus-4-7", |
| messages=[{"role": "user", "content": "hi"}], |
| tools=None, |
| max_tokens=1024, |
| reasoning_config=None, |
| preserve_dots=False, |
| ) |
| assert kwargs["model"] == "global-anthropic-claude-opus-4-7" |
|
|