mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat: native AWS Bedrock provider via Converse API
Salvaged from PR #7920 by JiaDe-Wu — cherry-picked Bedrock-specific additions onto current main, skipping stale-branch reverts (293 commits behind). Dual-path architecture: - Claude models → AnthropicBedrock SDK (prompt caching, thinking budgets) - Non-Claude models → Converse API via boto3 (Nova, DeepSeek, Llama, Mistral) Includes: - Core adapter (agent/bedrock_adapter.py, 1098 lines) - Full provider registration (auth, models, providers, config, runtime, main) - IAM credential chain + Bedrock API Key auth modes - Dynamic model discovery via ListFoundationModels + ListInferenceProfiles - Streaming with delta callbacks, error classification, guardrails - hermes doctor + hermes auth integration - /usage pricing for 7 Bedrock models - 130 automated tests (79 unit + 28 integration + follow-up fixes) - Documentation (website/docs/guides/aws-bedrock.md) - boto3 optional dependency (pip install hermes-agent[bedrock]) Co-authored-by: JiaDe WU <40445668+JiaDe-Wu@users.noreply.github.com>
This commit is contained in:
parent
21afc9502a
commit
0cb8c51fa5
18 changed files with 3543 additions and 20 deletions
|
|
@ -298,6 +298,33 @@ def build_anthropic_client(api_key: str, base_url: str = None):
|
|||
return _anthropic_sdk.Anthropic(**kwargs)
|
||||
|
||||
|
||||
def build_anthropic_bedrock_client(region: str):
|
||||
"""Create an AnthropicBedrock client for Bedrock Claude models.
|
||||
|
||||
Uses the Anthropic SDK's native Bedrock adapter, which provides full
|
||||
Claude feature parity: prompt caching, thinking budgets, adaptive
|
||||
thinking, fast mode — features not available via the Converse API.
|
||||
|
||||
Auth uses the boto3 default credential chain (IAM roles, SSO, env vars).
|
||||
"""
|
||||
if _anthropic_sdk is None:
|
||||
raise ImportError(
|
||||
"The 'anthropic' package is required for the Bedrock provider. "
|
||||
"Install it with: pip install 'anthropic>=0.39.0'"
|
||||
)
|
||||
if not hasattr(_anthropic_sdk, "AnthropicBedrock"):
|
||||
raise ImportError(
|
||||
"anthropic.AnthropicBedrock not available. "
|
||||
"Upgrade with: pip install 'anthropic>=0.39.0'"
|
||||
)
|
||||
from httpx import Timeout
|
||||
|
||||
return _anthropic_sdk.AnthropicBedrock(
|
||||
aws_region=region,
|
||||
timeout=Timeout(timeout=900.0, connect=10.0),
|
||||
)
|
||||
|
||||
|
||||
def read_claude_code_credentials() -> Optional[Dict[str, Any]]:
|
||||
"""Read refreshable Claude Code OAuth credentials from ~/.claude/.credentials.json.
|
||||
|
||||
|
|
|
|||
1098
agent/bedrock_adapter.py
Normal file
1098
agent/bedrock_adapter.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -112,6 +112,10 @@ _RATE_LIMIT_PATTERNS = [
|
|||
"please retry after",
|
||||
"resource_exhausted",
|
||||
"rate increased too quickly", # Alibaba/DashScope throttling
|
||||
# AWS Bedrock throttling
|
||||
"throttlingexception",
|
||||
"too many concurrent requests",
|
||||
"servicequotaexceededexception",
|
||||
]
|
||||
|
||||
# Usage-limit patterns that need disambiguation (could be billing OR rate_limit)
|
||||
|
|
@ -171,6 +175,11 @@ _CONTEXT_OVERFLOW_PATTERNS = [
|
|||
# Chinese error messages (some providers return these)
|
||||
"超过最大长度",
|
||||
"上下文长度",
|
||||
# AWS Bedrock Converse API error patterns
|
||||
"input is too long",
|
||||
"max input token",
|
||||
"input token",
|
||||
"exceeds the maximum number of input tokens",
|
||||
]
|
||||
|
||||
# Model not found patterns
|
||||
|
|
|
|||
|
|
@ -1012,6 +1012,16 @@ def get_model_context_length(
|
|||
if ctx:
|
||||
return ctx
|
||||
|
||||
# 4b. AWS Bedrock — use static context length table.
|
||||
# Bedrock's ListFoundationModels doesn't expose context window sizes,
|
||||
# so we maintain a curated table in bedrock_adapter.py.
|
||||
if provider == "bedrock" or (base_url and "bedrock-runtime" in base_url):
|
||||
try:
|
||||
from agent.bedrock_adapter import get_bedrock_context_length
|
||||
return get_bedrock_context_length(model)
|
||||
except ImportError:
|
||||
pass # boto3 not installed — fall through to generic resolution
|
||||
|
||||
# 5. Provider-aware lookups (before generic OpenRouter cache)
|
||||
# These are provider-specific and take priority over the generic OR cache,
|
||||
# since the same model can have different context limits per provider
|
||||
|
|
|
|||
|
|
@ -284,6 +284,80 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
|
|||
source_url="https://ai.google.dev/pricing",
|
||||
pricing_version="google-pricing-2026-03-16",
|
||||
),
|
||||
# AWS Bedrock — pricing per the Bedrock pricing page.
|
||||
# Bedrock charges the same per-token rates as the model provider but
|
||||
# through AWS billing. These are the on-demand prices (no commitment).
|
||||
# Source: https://aws.amazon.com/bedrock/pricing/
|
||||
(
|
||||
"bedrock",
|
||||
"anthropic.claude-opus-4-6",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("15.00"),
|
||||
output_cost_per_million=Decimal("75.00"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://aws.amazon.com/bedrock/pricing/",
|
||||
pricing_version="bedrock-pricing-2026-04",
|
||||
),
|
||||
(
|
||||
"bedrock",
|
||||
"anthropic.claude-sonnet-4-6",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("3.00"),
|
||||
output_cost_per_million=Decimal("15.00"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://aws.amazon.com/bedrock/pricing/",
|
||||
pricing_version="bedrock-pricing-2026-04",
|
||||
),
|
||||
(
|
||||
"bedrock",
|
||||
"anthropic.claude-sonnet-4-5",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("3.00"),
|
||||
output_cost_per_million=Decimal("15.00"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://aws.amazon.com/bedrock/pricing/",
|
||||
pricing_version="bedrock-pricing-2026-04",
|
||||
),
|
||||
(
|
||||
"bedrock",
|
||||
"anthropic.claude-haiku-4-5",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("0.80"),
|
||||
output_cost_per_million=Decimal("4.00"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://aws.amazon.com/bedrock/pricing/",
|
||||
pricing_version="bedrock-pricing-2026-04",
|
||||
),
|
||||
(
|
||||
"bedrock",
|
||||
"amazon.nova-pro",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("0.80"),
|
||||
output_cost_per_million=Decimal("3.20"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://aws.amazon.com/bedrock/pricing/",
|
||||
pricing_version="bedrock-pricing-2026-04",
|
||||
),
|
||||
(
|
||||
"bedrock",
|
||||
"amazon.nova-lite",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("0.06"),
|
||||
output_cost_per_million=Decimal("0.24"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://aws.amazon.com/bedrock/pricing/",
|
||||
pricing_version="bedrock-pricing-2026-04",
|
||||
),
|
||||
(
|
||||
"bedrock",
|
||||
"amazon.nova-micro",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("0.035"),
|
||||
output_cost_per_million=Decimal("0.14"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://aws.amazon.com/bedrock/pricing/",
|
||||
pricing_version="bedrock-pricing-2026-04",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -274,6 +274,14 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
|
|||
api_key_env_vars=("XIAOMI_API_KEY",),
|
||||
base_url_env_var="XIAOMI_BASE_URL",
|
||||
),
|
||||
"bedrock": ProviderConfig(
|
||||
id="bedrock",
|
||||
name="AWS Bedrock",
|
||||
auth_type="aws_sdk",
|
||||
inference_base_url="https://bedrock-runtime.us-east-1.amazonaws.com",
|
||||
api_key_env_vars=(),
|
||||
base_url_env_var="BEDROCK_BASE_URL",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -924,6 +932,7 @@ def resolve_provider(
|
|||
"qwen-portal": "qwen-oauth", "qwen-cli": "qwen-oauth", "qwen-oauth": "qwen-oauth",
|
||||
"hf": "huggingface", "hugging-face": "huggingface", "huggingface-hub": "huggingface",
|
||||
"mimo": "xiaomi", "xiaomi-mimo": "xiaomi",
|
||||
"aws": "bedrock", "aws-bedrock": "bedrock", "amazon-bedrock": "bedrock", "amazon": "bedrock",
|
||||
"go": "opencode-go", "opencode-go-sub": "opencode-go",
|
||||
"kilo": "kilocode", "kilo-code": "kilocode", "kilo-gateway": "kilocode",
|
||||
# Local server aliases — route through the generic custom provider
|
||||
|
|
@ -980,6 +989,15 @@ def resolve_provider(
|
|||
if has_usable_secret(os.getenv(env_var, "")):
|
||||
return pid
|
||||
|
||||
# AWS Bedrock — detect via boto3 credential chain (IAM roles, SSO, env vars).
|
||||
# This runs after API-key providers so explicit keys always win.
|
||||
try:
|
||||
from agent.bedrock_adapter import has_aws_credentials
|
||||
if has_aws_credentials():
|
||||
return "bedrock"
|
||||
except ImportError:
|
||||
pass # boto3 not installed — skip Bedrock auto-detection
|
||||
|
||||
raise AuthError(
|
||||
"No inference provider configured. Run 'hermes model' to choose a "
|
||||
"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]:
|
|||
pconfig = PROVIDER_REGISTRY.get(target)
|
||||
if pconfig and pconfig.auth_type == "api_key":
|
||||
return get_api_key_provider_status(target)
|
||||
# AWS SDK providers (Bedrock) — check via boto3 credential chain
|
||||
if pconfig and pconfig.auth_type == "aws_sdk":
|
||||
try:
|
||||
from agent.bedrock_adapter import has_aws_credentials
|
||||
return {"logged_in": has_aws_credentials(), "provider": target}
|
||||
except ImportError:
|
||||
return {"logged_in": False, "provider": target, "error": "boto3 not installed"}
|
||||
return {"logged_in": False}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -368,6 +368,27 @@ def _interactive_auth() -> None:
|
|||
print("=" * 50)
|
||||
|
||||
auth_list_command(SimpleNamespace(provider=None))
|
||||
|
||||
# Show AWS Bedrock credential status (not in the pool — uses boto3 chain)
|
||||
try:
|
||||
from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region
|
||||
if has_aws_credentials():
|
||||
auth_source = resolve_aws_auth_env_var() or "unknown"
|
||||
region = resolve_bedrock_region()
|
||||
print(f"bedrock (AWS SDK credential chain):")
|
||||
print(f" Auth: {auth_source}")
|
||||
print(f" Region: {region}")
|
||||
try:
|
||||
import boto3
|
||||
sts = boto3.client("sts", region_name=region)
|
||||
identity = sts.get_caller_identity()
|
||||
arn = identity.get("Arn", "unknown")
|
||||
print(f" Identity: {arn}")
|
||||
except Exception:
|
||||
print(f" Identity: (could not resolve — boto3 STS call failed)")
|
||||
print()
|
||||
except ImportError:
|
||||
pass # boto3 or bedrock_adapter not available
|
||||
print()
|
||||
|
||||
# Main menu
|
||||
|
|
|
|||
|
|
@ -419,6 +419,27 @@ DEFAULT_CONFIG = {
|
|||
"protect_last_n": 20, # minimum recent messages to keep uncompressed
|
||||
|
||||
},
|
||||
|
||||
# AWS Bedrock provider configuration.
|
||||
# Only used when model.provider is "bedrock".
|
||||
"bedrock": {
|
||||
"region": "", # AWS region for Bedrock API calls (empty = AWS_REGION env var → us-east-1)
|
||||
"discovery": {
|
||||
"enabled": True, # Auto-discover models via ListFoundationModels
|
||||
"provider_filter": [], # Only show models from these providers (e.g. ["anthropic", "amazon"])
|
||||
"refresh_interval": 3600, # Cache discovery results for this many seconds
|
||||
},
|
||||
"guardrail": {
|
||||
# Amazon Bedrock Guardrails — content filtering and safety policies.
|
||||
# Create a guardrail in the Bedrock console, then set the ID and version here.
|
||||
# See: https://docs.aws.amazon.com/bedrock/latest/userguide/guardrails.html
|
||||
"guardrail_identifier": "", # e.g. "abc123def456"
|
||||
"guardrail_version": "", # e.g. "1" or "DRAFT"
|
||||
"stream_processing_mode": "async", # "sync" or "async"
|
||||
"trace": "disabled", # "enabled", "disabled", or "enabled_full"
|
||||
},
|
||||
},
|
||||
|
||||
"smart_model_routing": {
|
||||
"enabled": False,
|
||||
"max_simple_chars": 160,
|
||||
|
|
@ -974,6 +995,22 @@ OPTIONAL_ENV_VARS = {
|
|||
"category": "provider",
|
||||
"advanced": True,
|
||||
},
|
||||
"AWS_REGION": {
|
||||
"description": "AWS region for Bedrock API calls (e.g. us-east-1, eu-central-1)",
|
||||
"prompt": "AWS Region",
|
||||
"url": "https://docs.aws.amazon.com/bedrock/latest/userguide/bedrock-regions.html",
|
||||
"password": False,
|
||||
"category": "provider",
|
||||
"advanced": True,
|
||||
},
|
||||
"AWS_PROFILE": {
|
||||
"description": "AWS named profile for Bedrock authentication (from ~/.aws/credentials)",
|
||||
"prompt": "AWS Profile",
|
||||
"url": None,
|
||||
"password": False,
|
||||
"category": "provider",
|
||||
"advanced": True,
|
||||
},
|
||||
|
||||
# ── Tool API keys ──
|
||||
"EXA_API_KEY": {
|
||||
|
|
|
|||
|
|
@ -860,6 +860,31 @@ def run_doctor(args):
|
|||
except Exception as _e:
|
||||
print(f"\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_e})', Colors.DIM)} ")
|
||||
|
||||
# -- AWS Bedrock --
|
||||
# Bedrock uses the AWS SDK credential chain, not API keys.
|
||||
try:
|
||||
from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region
|
||||
if has_aws_credentials():
|
||||
_auth_var = resolve_aws_auth_env_var()
|
||||
_region = resolve_bedrock_region()
|
||||
_label = "AWS Bedrock".ljust(20)
|
||||
print(f" Checking AWS Bedrock...", end="", flush=True)
|
||||
try:
|
||||
import boto3
|
||||
_br_client = boto3.client("bedrock", region_name=_region)
|
||||
_br_resp = _br_client.list_foundation_models()
|
||||
_model_count = len(_br_resp.get("modelSummaries", []))
|
||||
print(f"\r {color('✓', Colors.GREEN)} {_label} {color(f'({_auth_var}, {_region}, {_model_count} models)', Colors.DIM)} ")
|
||||
except ImportError:
|
||||
print(f"\r {color('⚠', Colors.YELLOW)} {_label} {color('(boto3 not installed — pip install hermes-agent[bedrock])', Colors.DIM)} ")
|
||||
issues.append("Install boto3 for Bedrock: pip install hermes-agent[bedrock]")
|
||||
except Exception as _e:
|
||||
_err_name = type(_e).__name__
|
||||
print(f"\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_err_name}: {_e})', Colors.DIM)} ")
|
||||
issues.append(f"AWS Bedrock: {_err_name} — check IAM permissions for bedrock:ListFoundationModels")
|
||||
except ImportError:
|
||||
pass # bedrock_adapter not available — skip silently
|
||||
|
||||
# =========================================================================
|
||||
# Check: Submodules
|
||||
# =========================================================================
|
||||
|
|
|
|||
|
|
@ -1139,6 +1139,8 @@ def select_provider_and_model(args=None):
|
|||
_model_flow_anthropic(config, current_model)
|
||||
elif selected_provider == "kimi-coding":
|
||||
_model_flow_kimi(config, current_model)
|
||||
elif selected_provider == "bedrock":
|
||||
_model_flow_bedrock(config, current_model)
|
||||
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"):
|
||||
_model_flow_api_key_provider(config, selected_provider, current_model)
|
||||
|
||||
|
|
@ -2425,6 +2427,252 @@ def _model_flow_kimi(config, current_model=""):
|
|||
print("No change.")
|
||||
|
||||
|
||||
def _model_flow_bedrock_api_key(config, region, current_model=""):
|
||||
"""Bedrock API Key mode — uses the OpenAI-compatible bedrock-mantle endpoint.
|
||||
|
||||
For developers who don't have an AWS account but received a Bedrock API Key
|
||||
from their AWS admin. Works like any OpenAI-compatible endpoint.
|
||||
"""
|
||||
from hermes_cli.auth import _prompt_model_selection, _save_model_choice, deactivate_provider
|
||||
from hermes_cli.config import load_config, save_config, get_env_value, save_env_value
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
|
||||
mantle_base_url = f"https://bedrock-mantle.{region}.api.aws/v1"
|
||||
|
||||
# Prompt for API key
|
||||
existing_key = get_env_value("AWS_BEARER_TOKEN_BEDROCK") or ""
|
||||
if existing_key:
|
||||
print(f" Bedrock API Key: {existing_key[:12]}... ✓")
|
||||
else:
|
||||
print(f" Endpoint: {mantle_base_url}")
|
||||
print()
|
||||
try:
|
||||
import getpass
|
||||
api_key = getpass.getpass(" Bedrock API Key: ").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
return
|
||||
if not api_key:
|
||||
print(" Cancelled.")
|
||||
return
|
||||
save_env_value("AWS_BEARER_TOKEN_BEDROCK", api_key)
|
||||
existing_key = api_key
|
||||
print(" ✓ API key saved.")
|
||||
print()
|
||||
|
||||
# Model selection — use static list (mantle doesn't need boto3 for discovery)
|
||||
model_list = _PROVIDER_MODELS.get("bedrock", [])
|
||||
print(f" Showing {len(model_list)} curated models")
|
||||
|
||||
if model_list:
|
||||
selected = _prompt_model_selection(model_list, current_model=current_model)
|
||||
else:
|
||||
try:
|
||||
selected = input(" Model ID: ").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
selected = None
|
||||
|
||||
if selected:
|
||||
_save_model_choice(selected)
|
||||
|
||||
# Save as custom provider pointing to bedrock-mantle
|
||||
cfg = load_config()
|
||||
model = cfg.get("model")
|
||||
if not isinstance(model, dict):
|
||||
model = {"default": model} if model else {}
|
||||
cfg["model"] = model
|
||||
model["provider"] = "custom"
|
||||
model["base_url"] = mantle_base_url
|
||||
model.pop("api_mode", None) # chat_completions is the default
|
||||
|
||||
# Also save region in bedrock config for reference
|
||||
bedrock_cfg = cfg.get("bedrock", {})
|
||||
if not isinstance(bedrock_cfg, dict):
|
||||
bedrock_cfg = {}
|
||||
bedrock_cfg["region"] = region
|
||||
cfg["bedrock"] = bedrock_cfg
|
||||
|
||||
# Save the API key env var name so hermes knows where to find it
|
||||
save_env_value("OPENAI_API_KEY", existing_key)
|
||||
save_env_value("OPENAI_BASE_URL", mantle_base_url)
|
||||
|
||||
save_config(cfg)
|
||||
deactivate_provider()
|
||||
|
||||
print(f" Default model set to: {selected} (via Bedrock API Key, {region})")
|
||||
print(f" Endpoint: {mantle_base_url}")
|
||||
else:
|
||||
print(" No change.")
|
||||
|
||||
|
||||
def _model_flow_bedrock(config, current_model=""):
|
||||
"""AWS Bedrock provider: verify credentials, pick region, discover models.
|
||||
|
||||
Uses the native Converse API via boto3 — not the OpenAI-compatible endpoint.
|
||||
Auth is handled by the AWS SDK default credential chain (env vars, profile,
|
||||
instance role), so no API key prompt is needed.
|
||||
"""
|
||||
from hermes_cli.auth import _prompt_model_selection, _save_model_choice, deactivate_provider
|
||||
from hermes_cli.config import load_config, save_config
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
|
||||
# 1. Check for AWS credentials
|
||||
try:
|
||||
from agent.bedrock_adapter import (
|
||||
has_aws_credentials,
|
||||
resolve_aws_auth_env_var,
|
||||
resolve_bedrock_region,
|
||||
discover_bedrock_models,
|
||||
)
|
||||
except ImportError:
|
||||
print(" ✗ boto3 is not installed. Install it with:")
|
||||
print(" pip install boto3")
|
||||
print()
|
||||
return
|
||||
|
||||
if not has_aws_credentials():
|
||||
print(" ⚠ No AWS credentials detected via environment variables.")
|
||||
print(" Bedrock will use boto3's default credential chain (IMDS, SSO, etc.)")
|
||||
print()
|
||||
|
||||
auth_var = resolve_aws_auth_env_var()
|
||||
if auth_var:
|
||||
print(f" AWS credentials: {auth_var} ✓")
|
||||
else:
|
||||
print(" AWS credentials: boto3 default chain (instance role / SSO)")
|
||||
print()
|
||||
|
||||
# 2. Region selection
|
||||
current_region = resolve_bedrock_region()
|
||||
try:
|
||||
region_input = input(f" AWS Region [{current_region}]: ").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
return
|
||||
region = region_input or current_region
|
||||
|
||||
# 2b. Authentication mode
|
||||
print(" Choose authentication method:")
|
||||
print()
|
||||
print(" 1. IAM credential chain (recommended)")
|
||||
print(" Works with EC2 instance roles, SSO, env vars, aws configure")
|
||||
print(" 2. Bedrock API Key")
|
||||
print(" Enter your Bedrock API Key directly — also supports")
|
||||
print(" team scenarios where an admin distributes keys")
|
||||
print()
|
||||
try:
|
||||
auth_choice = input(" Choice [1]: ").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
return
|
||||
|
||||
if auth_choice == "2":
|
||||
_model_flow_bedrock_api_key(config, region, current_model)
|
||||
return
|
||||
|
||||
# 3. Model discovery — try live API first, fall back to static list
|
||||
print(f" Discovering models in {region}...")
|
||||
live_models = discover_bedrock_models(region)
|
||||
|
||||
if live_models:
|
||||
_EXCLUDE_PREFIXES = (
|
||||
"stability.", "cohere.embed", "twelvelabs.", "us.stability.",
|
||||
"us.cohere.embed", "us.twelvelabs.", "global.cohere.embed",
|
||||
"global.twelvelabs.",
|
||||
)
|
||||
_EXCLUDE_SUBSTRINGS = ("safeguard", "voxtral", "palmyra-vision")
|
||||
filtered = []
|
||||
for m in live_models:
|
||||
mid = m["id"]
|
||||
if any(mid.startswith(p) for p in _EXCLUDE_PREFIXES):
|
||||
continue
|
||||
if any(s in mid.lower() for s in _EXCLUDE_SUBSTRINGS):
|
||||
continue
|
||||
filtered.append(m)
|
||||
|
||||
# Deduplicate: prefer inference profiles (us.*, global.*) over bare
|
||||
# foundation model IDs.
|
||||
profile_base_ids = set()
|
||||
for m in filtered:
|
||||
mid = m["id"]
|
||||
if mid.startswith(("us.", "global.")):
|
||||
base = mid.split(".", 1)[1] if "." in mid[3:] else mid
|
||||
profile_base_ids.add(base)
|
||||
|
||||
deduped = []
|
||||
for m in filtered:
|
||||
mid = m["id"]
|
||||
if not mid.startswith(("us.", "global.")) and mid in profile_base_ids:
|
||||
continue
|
||||
deduped.append(m)
|
||||
|
||||
_RECOMMENDED = [
|
||||
"us.anthropic.claude-sonnet-4-6",
|
||||
"us.anthropic.claude-opus-4-6",
|
||||
"us.anthropic.claude-haiku-4-5",
|
||||
"us.amazon.nova-pro",
|
||||
"us.amazon.nova-lite",
|
||||
"us.amazon.nova-micro",
|
||||
"deepseek.v3",
|
||||
"us.meta.llama4-maverick",
|
||||
"us.meta.llama4-scout",
|
||||
]
|
||||
|
||||
def _sort_key(m):
|
||||
mid = m["id"]
|
||||
for i, rec in enumerate(_RECOMMENDED):
|
||||
if mid.startswith(rec):
|
||||
return (0, i, mid)
|
||||
if mid.startswith("global."):
|
||||
return (1, 0, mid)
|
||||
return (2, 0, mid)
|
||||
|
||||
deduped.sort(key=_sort_key)
|
||||
model_list = [m["id"] for m in deduped]
|
||||
print(f" Found {len(model_list)} text model(s) (filtered from {len(live_models)} total)")
|
||||
else:
|
||||
model_list = _PROVIDER_MODELS.get("bedrock", [])
|
||||
if model_list:
|
||||
print(f" Using {len(model_list)} curated models (live discovery unavailable)")
|
||||
else:
|
||||
print(" No models found. Check IAM permissions for bedrock:ListFoundationModels.")
|
||||
return
|
||||
|
||||
# 4. Model selection
|
||||
if model_list:
|
||||
selected = _prompt_model_selection(model_list, current_model=current_model)
|
||||
else:
|
||||
try:
|
||||
selected = input(" Model ID: ").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
selected = None
|
||||
|
||||
if selected:
|
||||
_save_model_choice(selected)
|
||||
|
||||
cfg = load_config()
|
||||
model = cfg.get("model")
|
||||
if not isinstance(model, dict):
|
||||
model = {"default": model} if model else {}
|
||||
cfg["model"] = model
|
||||
model["provider"] = "bedrock"
|
||||
model["base_url"] = f"https://bedrock-runtime.{region}.amazonaws.com"
|
||||
model.pop("api_mode", None) # bedrock_converse is auto-detected
|
||||
|
||||
bedrock_cfg = cfg.get("bedrock", {})
|
||||
if not isinstance(bedrock_cfg, dict):
|
||||
bedrock_cfg = {}
|
||||
bedrock_cfg["region"] = region
|
||||
cfg["bedrock"] = bedrock_cfg
|
||||
|
||||
save_config(cfg)
|
||||
deactivate_provider()
|
||||
|
||||
print(f" Default model set to: {selected} (via AWS Bedrock, {region})")
|
||||
else:
|
||||
print(" No change.")
|
||||
|
||||
|
||||
def _model_flow_api_key_provider(config, provider_id, current_model=""):
|
||||
"""Generic flow for API-key providers (z.ai, MiniMax, OpenCode, etc.)."""
|
||||
from hermes_cli.auth import (
|
||||
|
|
|
|||
|
|
@ -303,6 +303,22 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
|||
"XiaomiMiMo/MiMo-V2-Flash",
|
||||
"moonshotai/Kimi-K2-Thinking",
|
||||
],
|
||||
# AWS Bedrock — static fallback list used when dynamic discovery is
|
||||
# unavailable (no boto3, no credentials, or API error). The agent
|
||||
# prefers live discovery via ListFoundationModels + ListInferenceProfiles.
|
||||
# Use inference profile IDs (us.*) since most models require them.
|
||||
"bedrock": [
|
||||
"us.anthropic.claude-sonnet-4-6",
|
||||
"us.anthropic.claude-opus-4-6-v1",
|
||||
"us.anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
"us.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
"us.amazon.nova-pro-v1:0",
|
||||
"us.amazon.nova-lite-v1:0",
|
||||
"us.amazon.nova-micro-v1:0",
|
||||
"deepseek.v3.2",
|
||||
"us.meta.llama4-maverick-17b-instruct-v1:0",
|
||||
"us.meta.llama4-scout-17b-instruct-v1:0",
|
||||
],
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -536,6 +552,7 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [
|
|||
ProviderEntry("opencode-zen", "OpenCode Zen", "OpenCode Zen (35+ curated models, pay-as-you-go)"),
|
||||
ProviderEntry("opencode-go", "OpenCode Go", "OpenCode Go (open models, $10/month subscription)"),
|
||||
ProviderEntry("ai-gateway", "Vercel AI Gateway", "Vercel AI Gateway (200+ models, pay-per-use)"),
|
||||
ProviderEntry("bedrock", "AWS Bedrock", "AWS Bedrock (Claude, Nova, Llama, DeepSeek — IAM or API key)"),
|
||||
]
|
||||
|
||||
# Derived dicts — used throughout the codebase
|
||||
|
|
@ -587,6 +604,10 @@ _PROVIDER_ALIASES = {
|
|||
"huggingface-hub": "huggingface",
|
||||
"mimo": "xiaomi",
|
||||
"xiaomi-mimo": "xiaomi",
|
||||
"aws": "bedrock",
|
||||
"aws-bedrock": "bedrock",
|
||||
"amazon-bedrock": "bedrock",
|
||||
"amazon": "bedrock",
|
||||
"grok": "xai",
|
||||
"x-ai": "xai",
|
||||
"x.ai": "xai",
|
||||
|
|
@ -1957,6 +1978,42 @@ def validate_requested_model(
|
|||
|
||||
# api_models is None — couldn't reach API. Accept and persist,
|
||||
# but warn so typos don't silently break things.
|
||||
|
||||
# Bedrock: use our own discovery instead of HTTP /models endpoint.
|
||||
# Bedrock's bedrock-runtime URL doesn't support /models — it uses the
|
||||
# AWS SDK control plane (ListFoundationModels + ListInferenceProfiles).
|
||||
if normalized == "bedrock":
|
||||
try:
|
||||
from agent.bedrock_adapter import discover_bedrock_models, resolve_bedrock_region
|
||||
region = resolve_bedrock_region()
|
||||
discovered = discover_bedrock_models(region)
|
||||
discovered_ids = {m["id"] for m in discovered}
|
||||
if requested in discovered_ids:
|
||||
return {
|
||||
"accepted": True,
|
||||
"persist": True,
|
||||
"recognized": True,
|
||||
"message": None,
|
||||
}
|
||||
# Not in discovered list — still accept (user may have custom
|
||||
# inference profiles or cross-account access), but warn.
|
||||
suggestions = get_close_matches(requested, list(discovered_ids), n=3, cutoff=0.4)
|
||||
suggestion_text = ""
|
||||
if suggestions:
|
||||
suggestion_text = "\n Similar models: " + ", ".join(f"`{s}`" for s in suggestions)
|
||||
return {
|
||||
"accepted": True,
|
||||
"persist": True,
|
||||
"recognized": False,
|
||||
"message": (
|
||||
f"Note: `{requested}` was not found in Bedrock model discovery for {region}. "
|
||||
f"It may still work with custom inference profiles or cross-account access."
|
||||
f"{suggestion_text}"
|
||||
),
|
||||
}
|
||||
except Exception:
|
||||
pass # Fall through to generic warning
|
||||
|
||||
provider_label = _PROVIDER_LABELS.get(normalized, normalized)
|
||||
return {
|
||||
"accepted": True,
|
||||
|
|
|
|||
|
|
@ -236,6 +236,12 @@ ALIASES: Dict[str, str] = {
|
|||
"mimo": "xiaomi",
|
||||
"xiaomi-mimo": "xiaomi",
|
||||
|
||||
# bedrock
|
||||
"aws": "bedrock",
|
||||
"aws-bedrock": "bedrock",
|
||||
"amazon-bedrock": "bedrock",
|
||||
"amazon": "bedrock",
|
||||
|
||||
# arcee
|
||||
"arcee-ai": "arcee",
|
||||
"arceeai": "arcee",
|
||||
|
|
@ -262,6 +268,7 @@ _LABEL_OVERRIDES: Dict[str, str] = {
|
|||
"copilot-acp": "GitHub Copilot ACP",
|
||||
"xiaomi": "Xiaomi MiMo",
|
||||
"local": "Local endpoint",
|
||||
"bedrock": "AWS Bedrock",
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -271,6 +278,7 @@ TRANSPORT_TO_API_MODE: Dict[str, str] = {
|
|||
"openai_chat": "chat_completions",
|
||||
"anthropic_messages": "anthropic_messages",
|
||||
"codex_responses": "codex_responses",
|
||||
"bedrock_converse": "bedrock_converse",
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -388,6 +396,10 @@ def determine_api_mode(provider: str, base_url: str = "") -> str:
|
|||
if pdef is not None:
|
||||
return TRANSPORT_TO_API_MODE.get(pdef.transport, "chat_completions")
|
||||
|
||||
# Direct provider checks for providers not in HERMES_OVERLAYS
|
||||
if provider == "bedrock":
|
||||
return "bedrock_converse"
|
||||
|
||||
# URL-based heuristics for custom / unknown providers
|
||||
if base_url:
|
||||
url_lower = base_url.rstrip("/").lower()
|
||||
|
|
@ -395,6 +407,8 @@ def determine_api_mode(provider: str, base_url: str = "") -> str:
|
|||
return "anthropic_messages"
|
||||
if "api.openai.com" in url_lower:
|
||||
return "codex_responses"
|
||||
if "bedrock-runtime" in url_lower and "amazonaws.com" in url_lower:
|
||||
return "bedrock_converse"
|
||||
|
||||
return "chat_completions"
|
||||
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ def _copilot_runtime_api_mode(model_cfg: Dict[str, Any], api_key: str) -> str:
|
|||
return "chat_completions"
|
||||
|
||||
|
||||
_VALID_API_MODES = {"chat_completions", "codex_responses", "anthropic_messages"}
|
||||
_VALID_API_MODES = {"chat_completions", "codex_responses", "anthropic_messages", "bedrock_converse"}
|
||||
|
||||
|
||||
def _parse_api_mode(raw: Any) -> Optional[str]:
|
||||
|
|
@ -836,6 +836,77 @@ def resolve_runtime_provider(
|
|||
"requested_provider": requested_provider,
|
||||
}
|
||||
|
||||
# AWS Bedrock (native Converse API via boto3)
|
||||
if provider == "bedrock":
|
||||
from agent.bedrock_adapter import (
|
||||
has_aws_credentials,
|
||||
resolve_aws_auth_env_var,
|
||||
resolve_bedrock_region,
|
||||
is_anthropic_bedrock_model,
|
||||
)
|
||||
# When the user explicitly selected bedrock (not auto-detected),
|
||||
# trust boto3's credential chain — it handles IMDS, ECS task roles,
|
||||
# Lambda execution roles, SSO, and other implicit sources that our
|
||||
# env-var check can't detect.
|
||||
is_explicit = requested_provider in ("bedrock", "aws", "aws-bedrock", "amazon-bedrock", "amazon")
|
||||
if not is_explicit and not has_aws_credentials():
|
||||
raise AuthError(
|
||||
"No AWS credentials found for Bedrock. Configure one of:\n"
|
||||
" - AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY\n"
|
||||
" - AWS_PROFILE (for SSO / named profiles)\n"
|
||||
" - IAM instance role (EC2, ECS, Lambda)\n"
|
||||
"Or run 'aws configure' to set up credentials.",
|
||||
code="no_aws_credentials",
|
||||
)
|
||||
# Read bedrock-specific config from config.yaml
|
||||
from hermes_cli.config import load_config as _load_bedrock_config
|
||||
_bedrock_cfg = _load_bedrock_config().get("bedrock", {})
|
||||
# Region priority: config.yaml bedrock.region → env var → us-east-1
|
||||
region = (_bedrock_cfg.get("region") or "").strip() or resolve_bedrock_region()
|
||||
auth_source = resolve_aws_auth_env_var() or "aws-sdk-default-chain"
|
||||
# Build guardrail config if configured
|
||||
_gr = _bedrock_cfg.get("guardrail", {})
|
||||
guardrail_config = None
|
||||
if _gr.get("guardrail_identifier") and _gr.get("guardrail_version"):
|
||||
guardrail_config = {
|
||||
"guardrailIdentifier": _gr["guardrail_identifier"],
|
||||
"guardrailVersion": _gr["guardrail_version"],
|
||||
}
|
||||
if _gr.get("stream_processing_mode"):
|
||||
guardrail_config["streamProcessingMode"] = _gr["stream_processing_mode"]
|
||||
if _gr.get("trace"):
|
||||
guardrail_config["trace"] = _gr["trace"]
|
||||
# Dual-path routing: Claude models use AnthropicBedrock SDK for full
|
||||
# feature parity (prompt caching, thinking budgets, adaptive thinking).
|
||||
# Non-Claude models use the Converse API for multi-model support.
|
||||
_current_model = str(model_cfg.get("default") or "").strip()
|
||||
if is_anthropic_bedrock_model(_current_model):
|
||||
# Claude on Bedrock → AnthropicBedrock SDK → anthropic_messages path
|
||||
runtime = {
|
||||
"provider": "bedrock",
|
||||
"api_mode": "anthropic_messages",
|
||||
"base_url": f"https://bedrock-runtime.{region}.amazonaws.com",
|
||||
"api_key": "aws-sdk",
|
||||
"source": auth_source,
|
||||
"region": region,
|
||||
"bedrock_anthropic": True, # Signal to use AnthropicBedrock client
|
||||
"requested_provider": requested_provider,
|
||||
}
|
||||
else:
|
||||
# Non-Claude (Nova, DeepSeek, Llama, etc.) → Converse API
|
||||
runtime = {
|
||||
"provider": "bedrock",
|
||||
"api_mode": "bedrock_converse",
|
||||
"base_url": f"https://bedrock-runtime.{region}.amazonaws.com",
|
||||
"api_key": "aws-sdk",
|
||||
"source": auth_source,
|
||||
"region": region,
|
||||
"requested_provider": requested_provider,
|
||||
}
|
||||
if guardrail_config:
|
||||
runtime["guardrail_config"] = guardrail_config
|
||||
return runtime
|
||||
|
||||
# API-key providers (z.ai/GLM, Kimi, MiniMax, MiniMax-CN)
|
||||
pconfig = PROVIDER_REGISTRY.get(provider)
|
||||
if pconfig and pconfig.auth_type == "api_key":
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ homeassistant = ["aiohttp>=3.9.0,<4"]
|
|||
sms = ["aiohttp>=3.9.0,<4"]
|
||||
acp = ["agent-client-protocol>=0.9.0,<1.0"]
|
||||
mistral = ["mistralai>=2.3.0,<3"]
|
||||
bedrock = ["boto3>=1.35.0,<2"]
|
||||
termux = [
|
||||
# Tested Android / Termux path: keeps the core CLI feature-rich while
|
||||
# avoiding extras that currently depend on non-Android wheels (notably
|
||||
|
|
@ -108,6 +109,7 @@ all = [
|
|||
"hermes-agent[dingtalk]",
|
||||
"hermes-agent[feishu]",
|
||||
"hermes-agent[mistral]",
|
||||
"hermes-agent[bedrock]",
|
||||
"hermes-agent[web]",
|
||||
]
|
||||
|
||||
|
|
|
|||
178
run_agent.py
178
run_agent.py
|
|
@ -685,7 +685,7 @@ class AIAgent:
|
|||
self.provider = provider_name or ""
|
||||
self.acp_command = acp_command or command
|
||||
self.acp_args = list(acp_args or args or [])
|
||||
if api_mode in {"chat_completions", "codex_responses", "anthropic_messages"}:
|
||||
if api_mode in {"chat_completions", "codex_responses", "anthropic_messages", "bedrock_converse"}:
|
||||
self.api_mode = api_mode
|
||||
elif self.provider == "openai-codex":
|
||||
self.api_mode = "codex_responses"
|
||||
|
|
@ -700,6 +700,9 @@ class AIAgent:
|
|||
# use a URL convention ending in /anthropic. Auto-detect these so the
|
||||
# Anthropic Messages API adapter is used instead of chat completions.
|
||||
self.api_mode = "anthropic_messages"
|
||||
elif self.provider == "bedrock" or "bedrock-runtime" in self._base_url_lower:
|
||||
# AWS Bedrock — auto-detect from provider name or base URL.
|
||||
self.api_mode = "bedrock_converse"
|
||||
else:
|
||||
self.api_mode = "chat_completions"
|
||||
|
||||
|
|
@ -892,24 +895,70 @@ class AIAgent:
|
|||
|
||||
if self.api_mode == "anthropic_messages":
|
||||
from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token
|
||||
# Only fall back to ANTHROPIC_TOKEN when the provider is actually Anthropic.
|
||||
# Other anthropic_messages providers (MiniMax, Alibaba, etc.) must use their own API key.
|
||||
# Falling back would send Anthropic credentials to third-party endpoints (Fixes #1739, #minimax-401).
|
||||
_is_native_anthropic = self.provider == "anthropic"
|
||||
effective_key = (api_key or resolve_anthropic_token() or "") if _is_native_anthropic else (api_key or "")
|
||||
self.api_key = effective_key
|
||||
self._anthropic_api_key = effective_key
|
||||
self._anthropic_base_url = base_url
|
||||
from agent.anthropic_adapter import _is_oauth_token as _is_oat
|
||||
self._is_anthropic_oauth = _is_oat(effective_key)
|
||||
self._anthropic_client = build_anthropic_client(effective_key, base_url)
|
||||
# No OpenAI client needed for Anthropic mode
|
||||
# Bedrock + Claude → use AnthropicBedrock SDK for full feature parity
|
||||
# (prompt caching, thinking budgets, adaptive thinking).
|
||||
_is_bedrock_anthropic = self.provider == "bedrock"
|
||||
if _is_bedrock_anthropic:
|
||||
from agent.anthropic_adapter import build_anthropic_bedrock_client
|
||||
import re as _re
|
||||
_region_match = _re.search(r"bedrock-runtime\.([a-z0-9-]+)\.", base_url or "")
|
||||
_br_region = _region_match.group(1) if _region_match else "us-east-1"
|
||||
self._bedrock_region = _br_region
|
||||
self._anthropic_client = build_anthropic_bedrock_client(_br_region)
|
||||
self._anthropic_api_key = "aws-sdk"
|
||||
self._anthropic_base_url = base_url
|
||||
self._is_anthropic_oauth = False
|
||||
self.api_key = "aws-sdk"
|
||||
self.client = None
|
||||
self._client_kwargs = {}
|
||||
if not self.quiet_mode:
|
||||
print(f"🤖 AI Agent initialized with model: {self.model} (AWS Bedrock + AnthropicBedrock SDK, {_br_region})")
|
||||
else:
|
||||
# Only fall back to ANTHROPIC_TOKEN when the provider is actually Anthropic.
|
||||
# Other anthropic_messages providers (MiniMax, Alibaba, etc.) must use their own API key.
|
||||
# Falling back would send Anthropic credentials to third-party endpoints (Fixes #1739, #minimax-401).
|
||||
_is_native_anthropic = self.provider == "anthropic"
|
||||
effective_key = (api_key or resolve_anthropic_token() or "") if _is_native_anthropic else (api_key or "")
|
||||
self.api_key = effective_key
|
||||
self._anthropic_api_key = effective_key
|
||||
self._anthropic_base_url = base_url
|
||||
from agent.anthropic_adapter import _is_oauth_token as _is_oat
|
||||
self._is_anthropic_oauth = _is_oat(effective_key)
|
||||
self._anthropic_client = build_anthropic_client(effective_key, base_url)
|
||||
# No OpenAI client needed for Anthropic mode
|
||||
self.client = None
|
||||
self._client_kwargs = {}
|
||||
if not self.quiet_mode:
|
||||
print(f"🤖 AI Agent initialized with model: {self.model} (Anthropic native)")
|
||||
if effective_key and len(effective_key) > 12:
|
||||
print(f"🔑 Using token: {effective_key[:8]}...{effective_key[-4:]}")
|
||||
elif self.api_mode == "bedrock_converse":
|
||||
# AWS Bedrock — uses boto3 directly, no OpenAI client needed.
|
||||
# Region is extracted from the base_url or defaults to us-east-1.
|
||||
import re as _re
|
||||
_region_match = _re.search(r"bedrock-runtime\.([a-z0-9-]+)\.", base_url or "")
|
||||
self._bedrock_region = _region_match.group(1) if _region_match else "us-east-1"
|
||||
# Guardrail config — read from config.yaml at init time.
|
||||
self._bedrock_guardrail_config = None
|
||||
try:
|
||||
from hermes_cli.config import load_config as _load_br_cfg
|
||||
_gr = _load_br_cfg().get("bedrock", {}).get("guardrail", {})
|
||||
if _gr.get("guardrail_identifier") and _gr.get("guardrail_version"):
|
||||
self._bedrock_guardrail_config = {
|
||||
"guardrailIdentifier": _gr["guardrail_identifier"],
|
||||
"guardrailVersion": _gr["guardrail_version"],
|
||||
}
|
||||
if _gr.get("stream_processing_mode"):
|
||||
self._bedrock_guardrail_config["streamProcessingMode"] = _gr["stream_processing_mode"]
|
||||
if _gr.get("trace"):
|
||||
self._bedrock_guardrail_config["trace"] = _gr["trace"]
|
||||
except Exception:
|
||||
pass
|
||||
self.client = None
|
||||
self._client_kwargs = {}
|
||||
if not self.quiet_mode:
|
||||
print(f"🤖 AI Agent initialized with model: {self.model} (Anthropic native)")
|
||||
if effective_key and len(effective_key) > 12:
|
||||
print(f"🔑 Using token: {effective_key[:8]}...{effective_key[-4:]}")
|
||||
_gr_label = " + Guardrails" if self._bedrock_guardrail_config else ""
|
||||
print(f"🤖 AI Agent initialized with model: {self.model} (AWS Bedrock, {self._bedrock_region}{_gr_label})")
|
||||
else:
|
||||
if api_key and base_url:
|
||||
# Explicit credentials from CLI/gateway — construct directly.
|
||||
|
|
@ -4896,6 +4945,17 @@ class AIAgent:
|
|||
)
|
||||
elif self.api_mode == "anthropic_messages":
|
||||
result["response"] = self._anthropic_messages_create(api_kwargs)
|
||||
elif self.api_mode == "bedrock_converse":
|
||||
# Bedrock uses boto3 directly — no OpenAI client needed.
|
||||
from agent.bedrock_adapter import (
|
||||
_get_bedrock_runtime_client,
|
||||
normalize_converse_response,
|
||||
)
|
||||
region = api_kwargs.pop("__bedrock_region__", "us-east-1")
|
||||
api_kwargs.pop("__bedrock_converse__", None)
|
||||
client = _get_bedrock_runtime_client(region)
|
||||
raw_response = client.converse(**api_kwargs)
|
||||
result["response"] = normalize_converse_response(raw_response)
|
||||
else:
|
||||
request_client_holder["client"] = self._create_request_openai_client(reason="chat_completion_request")
|
||||
result["response"] = request_client_holder["client"].chat.completions.create(**api_kwargs)
|
||||
|
|
@ -5135,6 +5195,65 @@ class AIAgent:
|
|||
finally:
|
||||
self._codex_on_first_delta = None
|
||||
|
||||
# Bedrock Converse uses boto3's converse_stream() with real-time delta
|
||||
# callbacks — same UX as Anthropic and chat_completions streaming.
|
||||
if self.api_mode == "bedrock_converse":
|
||||
result = {"response": None, "error": None}
|
||||
first_delta_fired = {"done": False}
|
||||
deltas_were_sent = {"yes": False}
|
||||
|
||||
def _fire_first():
|
||||
if not first_delta_fired["done"] and on_first_delta:
|
||||
first_delta_fired["done"] = True
|
||||
try:
|
||||
on_first_delta()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _bedrock_call():
|
||||
try:
|
||||
from agent.bedrock_adapter import (
|
||||
_get_bedrock_runtime_client,
|
||||
stream_converse_with_callbacks,
|
||||
)
|
||||
region = api_kwargs.pop("__bedrock_region__", "us-east-1")
|
||||
api_kwargs.pop("__bedrock_converse__", None)
|
||||
client = _get_bedrock_runtime_client(region)
|
||||
raw_response = client.converse_stream(**api_kwargs)
|
||||
|
||||
def _on_text(text):
|
||||
_fire_first()
|
||||
self._fire_stream_delta(text)
|
||||
deltas_were_sent["yes"] = True
|
||||
|
||||
def _on_tool(name):
|
||||
_fire_first()
|
||||
self._fire_tool_gen_started(name)
|
||||
|
||||
def _on_reasoning(text):
|
||||
_fire_first()
|
||||
self._fire_reasoning_delta(text)
|
||||
|
||||
result["response"] = stream_converse_with_callbacks(
|
||||
raw_response,
|
||||
on_text_delta=_on_text if self._has_stream_consumers() else None,
|
||||
on_tool_start=_on_tool,
|
||||
on_reasoning_delta=_on_reasoning if self.reasoning_callback or self.stream_delta_callback else None,
|
||||
on_interrupt_check=lambda: self._interrupt_requested,
|
||||
)
|
||||
except Exception as e:
|
||||
result["error"] = e
|
||||
|
||||
t = threading.Thread(target=_bedrock_call, daemon=True)
|
||||
t.start()
|
||||
while t.is_alive():
|
||||
t.join(timeout=0.3)
|
||||
if self._interrupt_requested:
|
||||
raise InterruptedError("Agent interrupted during Bedrock API call")
|
||||
if result["error"] is not None:
|
||||
raise result["error"]
|
||||
return result["response"]
|
||||
|
||||
result = {"response": None, "error": None}
|
||||
request_client_holder = {"client": None}
|
||||
first_delta_fired = {"done": False}
|
||||
|
|
@ -5765,6 +5884,8 @@ class AIAgent:
|
|||
# provider-specific exceptions like Copilot gpt-5-mini on
|
||||
# chat completions.
|
||||
fb_api_mode = "codex_responses"
|
||||
elif fb_provider == "bedrock" or "bedrock-runtime" in fb_base_url.lower():
|
||||
fb_api_mode = "bedrock_converse"
|
||||
|
||||
old_model = self.model
|
||||
self.model = fb_model
|
||||
|
|
@ -6244,6 +6365,25 @@ class AIAgent:
|
|||
fast_mode=(self.request_overrides or {}).get("speed") == "fast",
|
||||
)
|
||||
|
||||
# AWS Bedrock native Converse API — bypasses the OpenAI client entirely.
|
||||
# The adapter handles message/tool conversion and boto3 calls directly.
|
||||
if self.api_mode == "bedrock_converse":
|
||||
from agent.bedrock_adapter import build_converse_kwargs
|
||||
region = getattr(self, "_bedrock_region", None) or "us-east-1"
|
||||
guardrail = getattr(self, "_bedrock_guardrail_config", None)
|
||||
return {
|
||||
"__bedrock_converse__": True,
|
||||
"__bedrock_region__": region,
|
||||
**build_converse_kwargs(
|
||||
model=self.model,
|
||||
messages=api_messages,
|
||||
tools=self.tools,
|
||||
max_tokens=self.max_tokens or 4096,
|
||||
temperature=None, # Let the model use its default
|
||||
guardrail_config=guardrail,
|
||||
),
|
||||
}
|
||||
|
||||
if self.api_mode == "codex_responses":
|
||||
instructions = ""
|
||||
payload_messages = api_messages
|
||||
|
|
@ -8821,7 +8961,7 @@ class AIAgent:
|
|||
# targeted error instead of wasting 3 API calls.
|
||||
_trunc_content = None
|
||||
_trunc_has_tool_calls = False
|
||||
if self.api_mode == "chat_completions":
|
||||
if self.api_mode in ("chat_completions", "bedrock_converse"):
|
||||
_trunc_msg = response.choices[0].message if (hasattr(response, "choices") and response.choices) else None
|
||||
_trunc_content = getattr(_trunc_msg, "content", None) if _trunc_msg else None
|
||||
_trunc_has_tool_calls = bool(getattr(_trunc_msg, "tool_calls", None)) if _trunc_msg else False
|
||||
|
|
@ -8890,7 +9030,7 @@ class AIAgent:
|
|||
"error": _exhaust_error,
|
||||
}
|
||||
|
||||
if self.api_mode == "chat_completions":
|
||||
if self.api_mode in ("chat_completions", "bedrock_converse"):
|
||||
assistant_message = response.choices[0].message
|
||||
if not assistant_message.tool_calls:
|
||||
length_continue_retries += 1
|
||||
|
|
@ -8930,7 +9070,7 @@ class AIAgent:
|
|||
"error": "Response remained truncated after 3 continuation attempts",
|
||||
}
|
||||
|
||||
if self.api_mode == "chat_completions":
|
||||
if self.api_mode in ("chat_completions", "bedrock_converse"):
|
||||
assistant_message = response.choices[0].message
|
||||
if assistant_message.tool_calls:
|
||||
if truncated_tool_call_retries < 1:
|
||||
|
|
|
|||
1232
tests/agent/test_bedrock_adapter.py
Normal file
1232
tests/agent/test_bedrock_adapter.py
Normal file
File diff suppressed because it is too large
Load diff
269
tests/agent/test_bedrock_integration.py
Normal file
269
tests/agent/test_bedrock_integration.py
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
"""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
|
||||
# bedrock is in the registry, so resolve_provider should return it
|
||||
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
|
||||
|
||||
# Clear all other provider env vars
|
||||
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)
|
||||
|
||||
# Set AWS credentials
|
||||
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE")
|
||||
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
|
||||
|
||||
# Mock the auth store to have no active provider
|
||||
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")
|
||||
|
||||
# Mock resolve_provider to return bedrock
|
||||
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
|
||||
|
||||
# Clear all AWS env vars
|
||||
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 both the provider resolution and boto3's credential chain
|
||||
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
|
||||
|
||||
# No AWS env vars set — but explicit bedrock request should not raise
|
||||
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"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# providers.py integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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 # "bedrock" IS the canonical name, not an alias
|
||||
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"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error classifier integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pyproject.toml bedrock extra
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPackaging:
|
||||
"""Verify bedrock optional dependency is declared."""
|
||||
|
||||
def test_bedrock_extra_exists(self):
|
||||
import configparser
|
||||
from pathlib import Path
|
||||
# Read pyproject.toml to verify [bedrock] extra
|
||||
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
|
||||
164
website/docs/guides/aws-bedrock.md
Normal file
164
website/docs/guides/aws-bedrock.md
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
---
|
||||
sidebar_position: 14
|
||||
title: "AWS Bedrock"
|
||||
description: "Use Hermes Agent with Amazon Bedrock — native Converse API, IAM authentication, Guardrails, and cross-region inference"
|
||||
---
|
||||
|
||||
# AWS Bedrock
|
||||
|
||||
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.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **AWS credentials** — any source supported by the [boto3 credential chain](https://boto3.amazonaws.com/v1/documentation/api/latest/guide/credentials.html):
|
||||
- IAM instance role (EC2, ECS, Lambda — zero config)
|
||||
- `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` environment variables
|
||||
- `AWS_PROFILE` for SSO or named profiles
|
||||
- `aws configure` for local development
|
||||
- **boto3** — install with `pip install hermes-agent[bedrock]`
|
||||
- **IAM permissions** — at minimum:
|
||||
- `bedrock:InvokeModel` and `bedrock:InvokeModelWithResponseStream` (for inference)
|
||||
- `bedrock:ListFoundationModels` and `bedrock:ListInferenceProfiles` (for model discovery)
|
||||
|
||||
:::tip EC2 / ECS / Lambda
|
||||
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.
|
||||
:::
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install with Bedrock support
|
||||
pip install hermes-agent[bedrock]
|
||||
|
||||
# Select Bedrock as your provider
|
||||
hermes model
|
||||
# → Choose "More providers..." → "AWS Bedrock"
|
||||
# → Select your region and model
|
||||
|
||||
# Start chatting
|
||||
hermes chat
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
After running `hermes model`, your `~/.hermes/config.yaml` will contain:
|
||||
|
||||
```yaml
|
||||
model:
|
||||
default: us.anthropic.claude-sonnet-4-6
|
||||
provider: bedrock
|
||||
base_url: https://bedrock-runtime.us-east-2.amazonaws.com
|
||||
|
||||
bedrock:
|
||||
region: us-east-2
|
||||
```
|
||||
|
||||
### Region
|
||||
|
||||
Set the AWS region in any of these ways (highest priority first):
|
||||
|
||||
1. `bedrock.region` in `config.yaml`
|
||||
2. `AWS_REGION` environment variable
|
||||
3. `AWS_DEFAULT_REGION` environment variable
|
||||
4. Default: `us-east-1`
|
||||
|
||||
### Guardrails
|
||||
|
||||
To apply [Amazon Bedrock Guardrails](https://docs.aws.amazon.com/bedrock/latest/userguide/guardrails.html) to all model invocations:
|
||||
|
||||
```yaml
|
||||
bedrock:
|
||||
region: us-east-2
|
||||
guardrail:
|
||||
guardrail_identifier: "abc123def456" # From the Bedrock console
|
||||
guardrail_version: "1" # Version number or "DRAFT"
|
||||
stream_processing_mode: "async" # "sync" or "async"
|
||||
trace: "disabled" # "enabled", "disabled", or "enabled_full"
|
||||
```
|
||||
|
||||
### Model Discovery
|
||||
|
||||
Hermes auto-discovers available models via the Bedrock control plane. You can customize discovery:
|
||||
|
||||
```yaml
|
||||
bedrock:
|
||||
discovery:
|
||||
enabled: true
|
||||
provider_filter: ["anthropic", "amazon"] # Only show these providers
|
||||
refresh_interval: 3600 # Cache for 1 hour
|
||||
```
|
||||
|
||||
## Available Models
|
||||
|
||||
Bedrock models use **inference profile IDs** for on-demand invocation. The `hermes model` picker shows these automatically, with recommended models at the top:
|
||||
|
||||
| Model | ID | Notes |
|
||||
|-------|-----|-------|
|
||||
| Claude Sonnet 4.6 | `us.anthropic.claude-sonnet-4-6` | Recommended — best balance of speed and capability |
|
||||
| Claude Opus 4.6 | `us.anthropic.claude-opus-4-6-v1` | Most capable |
|
||||
| Claude Haiku 4.5 | `us.anthropic.claude-haiku-4-5-20251001-v1:0` | Fastest Claude |
|
||||
| Amazon Nova Pro | `us.amazon.nova-pro-v1:0` | Amazon's flagship |
|
||||
| Amazon Nova Micro | `us.amazon.nova-micro-v1:0` | Fastest, cheapest |
|
||||
| DeepSeek V3.2 | `deepseek.v3.2` | Strong open model |
|
||||
| Llama 4 Scout 17B | `us.meta.llama4-scout-17b-instruct-v1:0` | Meta's latest |
|
||||
|
||||
:::info Cross-Region Inference
|
||||
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.
|
||||
:::
|
||||
|
||||
## Switching Models Mid-Session
|
||||
|
||||
Use the `/model` command during a conversation:
|
||||
|
||||
```
|
||||
/model us.amazon.nova-pro-v1:0
|
||||
/model deepseek.v3.2
|
||||
/model us.anthropic.claude-opus-4-6-v1
|
||||
```
|
||||
|
||||
## Diagnostics
|
||||
|
||||
```bash
|
||||
hermes doctor
|
||||
```
|
||||
|
||||
The doctor checks:
|
||||
- Whether AWS credentials are available (env vars, IAM role, SSO)
|
||||
- Whether `boto3` is installed
|
||||
- Whether the Bedrock API is reachable (ListFoundationModels)
|
||||
- Number of available models in your region
|
||||
|
||||
## Gateway (Messaging Platforms)
|
||||
|
||||
Bedrock works with all Hermes gateway platforms (Telegram, Discord, Slack, Feishu, etc.). Configure Bedrock as your provider, then start the gateway normally:
|
||||
|
||||
```bash
|
||||
hermes gateway setup
|
||||
hermes gateway start
|
||||
```
|
||||
|
||||
The gateway reads `config.yaml` and uses the same Bedrock provider configuration.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "No API key found" / "No AWS credentials"
|
||||
|
||||
Hermes checks for credentials in this order:
|
||||
1. `AWS_BEARER_TOKEN_BEDROCK`
|
||||
2. `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY`
|
||||
3. `AWS_PROFILE`
|
||||
4. EC2 instance metadata (IMDS)
|
||||
5. ECS container credentials
|
||||
6. Lambda execution role
|
||||
|
||||
If none are found, run `aws configure` or attach an IAM role to your compute instance.
|
||||
|
||||
### "Invocation of model ID ... with on-demand throughput isn't supported"
|
||||
|
||||
Use an **inference profile ID** (prefixed with `us.` or `global.`) instead of the bare foundation model ID. For example:
|
||||
- ❌ `anthropic.claude-sonnet-4-6`
|
||||
- ✅ `us.anthropic.claude-sonnet-4-6`
|
||||
|
||||
### "ThrottlingException"
|
||||
|
||||
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/).
|
||||
Loading…
Add table
Add a link
Reference in a new issue