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
14 changes: 14 additions & 0 deletions agent/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -795,6 +795,7 @@ def _moa_reference_relay(event: str, **kwargs: Any) -> None:
_gr_label = " + Guardrails" if agent._bedrock_guardrail_config else ""
print(f"🤖 AI Agent initialized with model: {agent.model} (AWS Bedrock, {agent._bedrock_region}{_gr_label})")
else:
client_kwargs = {}
if api_key and base_url:
# Explicit credentials from CLI/gateway — construct directly.
# The runtime provider resolver already handled auth for us.
Expand Down Expand Up @@ -945,6 +946,19 @@ def _moa_reference_relay(event: str, **kwargs: Any) -> None:
"select a provider, or run `hermes setup` for first-time "
"configuration."
)
# Bedrock GPT-5.5 uses Bedrock Mantle's OpenAI Responses endpoint.
# Runtime resolution uses api_key="aws-sdk" as the IAM-auth sentinel;
# attach an httpx client that SigV4-signs every OpenAI SDK request.
if "client_kwargs" in locals():
try:
from agent.bedrock_adapter import configure_bedrock_openai_client_kwargs
configure_bedrock_openai_client_kwargs(
client_kwargs,
timeout=_provider_timeout,
)
except Exception:
if agent.provider == "bedrock" and "bedrock-mantle." in str(client_kwargs.get("base_url", "")):
raise

agent._client_kwargs = client_kwargs # stored for rebuilding after interrupt

Expand Down
35 changes: 30 additions & 5 deletions agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4442,14 +4442,21 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
return None, None

elif pconfig.auth_type == "aws_sdk":
# AWS SDK providers (Bedrock)use the Anthropic Bedrock client via
# boto3's credential chain (IAM roles, SSO, env vars, instance metadata).
# AWS SDK providers (Bedrock). Claude models use AnthropicBedrock;
# OpenAI GPT-5.5 uses Bedrock Mantle's OpenAI Responses endpoint.
try:
from agent.bedrock_adapter import has_aws_credentials, resolve_bedrock_region
from agent.bedrock_adapter import (
has_aws_credentials,
resolve_bedrock_region,
is_openai_bedrock_model,
bedrock_openai_base_url,
resolve_bedrock_bearer_token,
configure_bedrock_openai_client_kwargs,
)
from agent.anthropic_adapter import build_anthropic_bedrock_client
except ImportError:
logger.warning("resolve_provider_client: bedrock requested but "
"boto3 or anthropic SDK not installed")
"boto3, httpx/openai, or anthropic SDK not installed")
return None, None

if not has_aws_credentials():
Expand All @@ -4459,7 +4466,25 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",

region = resolve_bedrock_region()
default_model = "anthropic.claude-haiku-4-5-20251001-v1:0"
final_model = _normalize_resolved_model(model or default_model, provider)
final_model = _normalize_resolved_model(model or default_model, provider) or default_model

if is_openai_bedrock_model(final_model):
bearer = resolve_bedrock_bearer_token()
base_url = bedrock_openai_base_url(region)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This rebuilds the endpoint from the ambient AWS region and ignores explicit_base_url forwarded from the active main runtime. Please preserve/parse that Mantle URL (or reuse runtime resolution), otherwise bedrock.region can select one region for the main agent and another for auxiliary calls.

client_kwargs: Dict[str, Any] = {
"api_key": bearer or "aws-sdk",
"base_url": base_url,
}
configure_bedrock_openai_client_kwargs(client_kwargs)
client = OpenAI(**client_kwargs)
logger.debug("resolve_provider_client: bedrock-openai (%s, %s)", final_model, region)
if raw_codex:
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
else (client, final_model))
wrapped = CodexAuxiliaryClient(client, final_model)
return (_to_async_client(wrapped, final_model, is_vision=is_vision) if async_mode
else (wrapped, final_model))

try:
real_client = build_anthropic_bedrock_client(region)
except ImportError as exc:
Expand Down
154 changes: 153 additions & 1 deletion agent/bedrock_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@
import re
from types import SimpleNamespace
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import urlparse

import httpx

logger = logging.getLogger(__name__)

Expand All @@ -57,6 +60,18 @@
_bedrock_runtime_client_cache: Dict[str, Any] = {}
_bedrock_control_client_cache: Dict[str, Any] = {}

# Bedrock-hosted OpenAI GPT-5.5 is not exposed through the native Converse
# runtime. AWS serves it from the Bedrock Mantle OpenAI-compatible Responses
# endpoint instead (https://bedrock-mantle.<region>.api.aws/openai/v1).
# Keep the allowlist intentionally narrow so OpenAI GPT-OSS models that are
# Converse-capable continue to use the native Bedrock path.
BEDROCK_OPENAI_RESPONSES_MODEL_IDS: Tuple[str, ...] = (
"openai.gpt-5.5",
)
_BEDROCK_OPENAI_HOST_RE = re.compile(
r"^bedrock-mantle\.([a-z0-9-]+)\.api\.aws$", re.IGNORECASE
)


_MIN_BOTO3_VERSION = (1, 34, 59)

Expand Down Expand Up @@ -133,6 +148,143 @@ def invalidate_runtime_client(region: str) -> bool:
return existed


# ---------------------------------------------------------------------------
# Bedrock Mantle / OpenAI Responses support
# ---------------------------------------------------------------------------


def is_openai_bedrock_model(model_id: str) -> bool:
"""Return True for Bedrock-hosted OpenAI models that require Mantle.

Bedrock's GPT-OSS models are Converse-capable and intentionally do not
match this helper. The allowlist tracks models served by the OpenAI
Responses-compatible ``bedrock-mantle`` route.
"""
normalized = str(model_id or "").strip().lower()
return normalized in {m.lower() for m in BEDROCK_OPENAI_RESPONSES_MODEL_IDS}


def merge_bedrock_openai_model_ids(model_ids: List[str]) -> List[str]:
"""Append Bedrock OpenAI Responses models to a discovered Bedrock list.

The Bedrock control plane's ListFoundationModels/ListInferenceProfiles
discovery covers Converse models but does not enumerate Mantle-only
OpenAI Responses models. The picker needs both surfaces under AWS Bedrock.
"""
merged = list(model_ids or [])
seen = {str(m).lower() for m in merged}
for model_id in BEDROCK_OPENAI_RESPONSES_MODEL_IDS:
if model_id.lower() not in seen:
merged.append(model_id)
seen.add(model_id.lower())
return merged


def bedrock_openai_base_url(region: str) -> str:
"""Return Bedrock Mantle's OpenAI-compatible base URL for *region*."""
resolved = (region or "").strip() or resolve_bedrock_region()
return f"https://bedrock-mantle.{resolved}.api.aws/openai/v1"


def bedrock_openai_region_from_base_url(base_url: str) -> Optional[str]:
"""Extract the AWS region from a Bedrock Mantle OpenAI base URL."""
host = urlparse(str(base_url or "")).hostname or ""
match = _BEDROCK_OPENAI_HOST_RE.match(host)
return match.group(1) if match else None


def is_bedrock_openai_base_url(base_url: str) -> bool:
"""Return True for Bedrock Mantle OpenAI-compatible endpoints."""
parsed = urlparse(str(base_url or ""))
host = parsed.hostname or ""
if not _BEDROCK_OPENAI_HOST_RE.match(host):
return False
# The OpenAI GPT-5.5 Bedrock route lives under /openai/v1. Accept a bare
# host too so callers can normalize before appending the path.
path = (parsed.path or "").rstrip("/").lower()
return path in {"", "/openai", "/openai/v1"}


def resolve_bedrock_bearer_token(env: Optional[Dict[str, str]] = None) -> str:
"""Return AWS_BEARER_TOKEN_BEDROCK when Bedrock API-key auth is configured."""
env = env if env is not None else os.environ
return (env.get("AWS_BEARER_TOKEN_BEDROCK", "") or "").strip()


class BedrockOpenAISigV4Auth(httpx.Auth):
"""httpx auth hook that SigV4-signs Bedrock Mantle OpenAI requests."""

requires_request_body = True

def __init__(self, region: str, service: str = "bedrock"):
self.region = (region or "").strip() or resolve_bedrock_region()
self.service = service

def auth_flow(self, request): # pragma: no cover - exercised by live call
import botocore.session
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest

credentials = botocore.session.get_session().get_credentials()
if credentials is None:
raise RuntimeError(
"No AWS credentials available for Bedrock OpenAI Responses. "
"Configure AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY, AWS_PROFILE, "
"SSO, or an instance/task role."
)
frozen = credentials.get_frozen_credentials()
# Drop the OpenAI SDK's placeholder bearer header before signing; SigV4
# must own Authorization. Keep all other SDK headers so AWS receives
# content-type, accept, request IDs, etc.
headers = {
str(k): str(v)
for k, v in request.headers.items()
if str(k).lower() not in {"authorization", "x-amz-date", "x-amz-security-token"}
}
aws_request = AWSRequest(
method=request.method,
url=str(request.url),
data=request.content or b"",
headers=headers,
)
SigV4Auth(frozen, self.service, self.region).add_auth(aws_request)
request.headers.update(dict(aws_request.headers.items()))
yield request


def build_bedrock_openai_http_client(region: str, *, timeout: Optional[float] = None):
"""Build an httpx client that SigV4-signs Bedrock OpenAI requests."""
import httpx

kwargs: Dict[str, Any] = {"auth": BedrockOpenAISigV4Auth(region)}
if isinstance(timeout, (int, float)) and not isinstance(timeout, bool) and timeout > 0:
kwargs["timeout"] = timeout
return httpx.Client(**kwargs)


def configure_bedrock_openai_client_kwargs(
client_kwargs: Dict[str, Any],
*,
timeout: Optional[float] = None,
) -> Dict[str, Any]:
"""Install SigV4 auth on OpenAI SDK kwargs for Bedrock Mantle.

``AWS_BEARER_TOKEN_BEDROCK``/explicit Bedrock API keys continue to use the
SDK's normal bearer auth. The special ``aws-sdk`` placeholder means IAM
credential-chain auth, so we attach a per-request SigV4 httpx client.
"""
base_url = str(client_kwargs.get("base_url") or "")
if not is_bedrock_openai_base_url(base_url):
return client_kwargs
api_key = client_kwargs.get("api_key")
if isinstance(api_key, str) and api_key.strip() and api_key not in {"aws-sdk", "no-key-required"}:
return client_kwargs
region = bedrock_openai_region_from_base_url(base_url) or resolve_bedrock_region()
client_kwargs["api_key"] = "aws-sdk"
client_kwargs["http_client"] = build_bedrock_openai_http_client(region, timeout=timeout)
return client_kwargs


# ---------------------------------------------------------------------------
# Stale-connection detection
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -398,7 +550,7 @@ def bedrock_model_ids_or_none() -> Optional[List[str]]:
try:
discovered = discover_bedrock_models(resolve_bedrock_region())
if discovered:
return [m["id"] for m in discovered]
return merge_bedrock_openai_model_ids([m["id"] for m in discovered])
except Exception:
pass
return None
Expand Down
6 changes: 4 additions & 2 deletions agent/moa_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,10 @@ def _slot_runtime(slot: dict[str, str]) -> dict[str, Any]:
# correct for ordinary OpenAI-compatible targets, but wrong for OAuth /
# provider-backed targets whose provider branch adds auth refresh,
# request metadata, or request-shape adapters. Keep those providers
# identified by name.
if resolved_provider in {"nous", "openai-codex", "xai-oauth"}:
# identified by name. The same applies to Bedrock: its api_key="aws-sdk"
# is a sentinel for IAM auth, not a bearer token for a generic custom
# endpoint.
if resolved_provider in {"nous", "openai-codex", "xai-oauth", "bedrock"}:
return out
# Pass the resolved endpoint through so call_llm builds the request for
# the provider's actual API surface instead of auto-detecting. base_url
Expand Down
1 change: 1 addition & 0 deletions hermes_cli/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,7 @@ def _xai_curated_models() -> list[str]:
"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",
"openai.gpt-5.5",
"us.amazon.nova-pro-v1:0",
"us.amazon.nova-lite-v1:0",
"us.amazon.nova-micro-v1:0",
Expand Down
29 changes: 24 additions & 5 deletions hermes_cli/runtime_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -1725,6 +1725,9 @@ def resolve_runtime_provider(
resolve_aws_auth_env_var,
resolve_bedrock_region,
is_anthropic_bedrock_model,
is_openai_bedrock_model,
bedrock_openai_base_url,
resolve_bedrock_bearer_token,
)
# When the user explicitly selected bedrock (not auto-detected),
# trust boto3's credential chain — it handles IMDS, ECS task roles,
Expand Down Expand Up @@ -1757,11 +1760,27 @@ def resolve_runtime_provider(
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.
# Triple-path routing:
# - OpenAI GPT-5.5 on Bedrock uses Bedrock Mantle's OpenAI Responses
# endpoint (not Converse / bedrock-runtime).
# - Claude models use AnthropicBedrock SDK for prompt caching,
# thinking budgets, and adaptive thinking.
# - Other models use the native Converse API.
_current_model = str(target_model or model_cfg.get("default") or "").strip()
if is_anthropic_bedrock_model(_current_model):
if is_openai_bedrock_model(_current_model):
bearer = resolve_bedrock_bearer_token()
runtime = {
"provider": "bedrock",
"api_mode": "codex_responses",
"base_url": bedrock_openai_base_url(region),
"api_key": bearer or "aws-sdk",
"source": "AWS_BEARER_TOKEN_BEDROCK" if bearer else auth_source,
"region": region,
"model": _current_model,
"bedrock_openai": True,
"requested_provider": requested_provider,
}
elif is_anthropic_bedrock_model(_current_model):
# Claude on Bedrock → AnthropicBedrock SDK → anthropic_messages path
runtime = {
"provider": "bedrock",
Expand All @@ -1774,7 +1793,7 @@ def resolve_runtime_provider(
"requested_provider": requested_provider,
}
else:
# Non-Claude (Nova, DeepSeek, Llama, etc.) → Converse API
# Non-Claude/OpenAI (Nova, DeepSeek, Llama, GPT-OSS, etc.) → Converse API
runtime = {
"provider": "bedrock",
"api_mode": "bedrock_converse",
Expand Down
Loading