Skip to content
Open
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 @@ -944,6 +944,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 @@ -1094,6 +1095,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/5.6 use 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.
# No-op for non-Mantle base URLs.
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
49 changes: 42 additions & 7 deletions agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5323,8 +5323,12 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
default_model = "google/gemini-3-flash-preview"
final_model = _normalize_resolved_model(model or default_model, provider)
try:
from openai import OpenAI
client = OpenAI(api_key=token, base_url=base_url)
# Alias the import: a bare `from openai import OpenAI` here would
# make `OpenAI` function-local and shadow the module-level lazy
# proxy for every other branch of this function (breaking both the
# Bedrock Mantle branch below and patch("agent.auxiliary_client.OpenAI")).
from openai import OpenAI as _VertexOpenAI
client = _VertexOpenAI(api_key=token, base_url=base_url)
except Exception as exc:
logger.warning("resolve_provider_client: cannot create Vertex "
"client: %s", exc)
Expand All @@ -5335,27 +5339,58 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",

elif pconfig.auth_type == "aws_sdk":
# AWS SDK providers (Bedrock) — Claude models use the Anthropic Bedrock
# SDK (prompt caching, thinking); non-Claude models use Converse API.
# SDK (prompt caching, thinking); OpenAI models (GPT-5.5/5.6) use
# Bedrock Mantle's OpenAI Responses endpoint; all other models use the
# Converse API.
try:
from agent.bedrock_adapter import (
has_aws_credentials,
is_anthropic_bedrock_model,
resolve_bedrock_region,
resolve_bedrock_runtime_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():
logger.debug("resolve_provider_client: bedrock requested but "
"no AWS credentials found")
return None, None

region = resolve_bedrock_region()
# Region must match the main runtime's resolution (bedrock.region in
# config.yaml first, then env/profile) — see review on #53880/#65076:
# a bare resolve_bedrock_region() here let auxiliary calls (compression,
# memory, vision) leave the primary runtime's configured region.
region = resolve_bedrock_runtime_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):
# NOTE: no local `from openai import OpenAI` here — the module-level
# lazy proxy (see top of file) must stay visible so tests can
# patch("agent.auxiliary_client.OpenAI", ...).
bearer = resolve_bedrock_bearer_token()
mantle_base_url = bedrock_openai_base_url(region)
client_kwargs: Dict[str, Any] = {
"api_key": bearer or "aws-sdk",
"base_url": mantle_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))

base_url = f"https://bedrock-runtime.{region}.amazonaws.com"

if is_anthropic_bedrock_model(final_model):
Expand Down
199 changes: 197 additions & 2 deletions 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,25 @@
_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",
# GPT-5.6 family (GA on Bedrock 2026-07-13): Sol (frontier), Terra
# (balanced), Luna (fast/affordable). All are Mantle-only — the model
# cards list bedrock-runtime/Converse as unsupported.
# https://docs.aws.amazon.com/bedrock/latest/userguide/model-cards-openai.html
"openai.gpt-5.6-sol",
"openai.gpt-5.6-terra",
"openai.gpt-5.6-luna",
)
_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 +155,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_runtime_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_runtime_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_runtime_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 @@ -384,6 +543,36 @@ def resolve_bedrock_region(env: Optional[Dict[str, str]] = None) -> str:
return "us-east-1"


def resolve_bedrock_runtime_region(config: Optional[Dict[str, Any]] = None) -> str:
"""Resolve the Bedrock region with the same priority as the main runtime.

Priority (matches the runtime provider resolver in
``hermes_cli/runtime_provider.py``):
1. ``bedrock.region`` in config.yaml
2. ``resolve_bedrock_region()`` (AWS_REGION / AWS_DEFAULT_REGION /
botocore profile / us-east-1)

Callers that already hold a loaded config dict should pass it to avoid a
disk read; when *config* is None the config is loaded read-only. Every
non-runtime call site that constructs a Bedrock endpoint (auxiliary
client resolution, model discovery for the picker) must use this helper —
using bare ``resolve_bedrock_region()`` there lets auxiliary calls leave
the primary runtime's configured region when ``bedrock.region`` and the
ambient AWS env/profile disagree.
"""
if config is None:
try:
from hermes_cli.config import load_config_readonly
config = load_config_readonly()
except Exception:
config = {}
bedrock_cfg = (config or {}).get("bedrock") or {}
cfg_region = str(bedrock_cfg.get("region") or "").strip()
if cfg_region:
return cfg_region
return resolve_bedrock_region()


def bedrock_model_ids_or_none() -> Optional[List[str]]:
"""Live-discover Bedrock model IDs for the active region.

Expand All @@ -396,9 +585,9 @@ def bedrock_model_ids_or_none() -> Optional[List[str]]:
``list_authenticated_providers`` section 2, and section 3.
"""
try:
discovered = discover_bedrock_models(resolve_bedrock_region())
discovered = discover_bedrock_models(resolve_bedrock_runtime_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 Expand Up @@ -1329,6 +1518,12 @@ def classify_bedrock_error(error_message: str) -> str:
"mistral.mistral-large": 128_000,
# DeepSeek
"deepseek.v3": 128_000,
# OpenAI on Bedrock (Mantle/Responses route)
# https://docs.aws.amazon.com/bedrock/latest/userguide/model-cards-openai.html
"openai.gpt-5.5": 272_000,
"openai.gpt-5.6-sol": 272_000,
"openai.gpt-5.6-terra": 272_000,
"openai.gpt-5.6-luna": 272_000,
}

# Default for unknown Bedrock models
Expand Down
8 changes: 6 additions & 2 deletions hermes_cli/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,10 @@ 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",
"openai.gpt-5.6-sol",
"openai.gpt-5.6-terra",
"openai.gpt-5.6-luna",
"us.amazon.nova-pro-v1:0",
"us.amazon.nova-lite-v1:0",
"us.amazon.nova-micro-v1:0",
Expand Down Expand Up @@ -4625,8 +4629,8 @@ def validate_requested_model(
# 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()
from agent.bedrock_adapter import discover_bedrock_models, resolve_bedrock_runtime_region
region = resolve_bedrock_runtime_region()
discovered = discover_bedrock_models(region)
discovered_ids = {m["id"] for m in discovered}
if requested in discovered_ids:
Expand Down
Loading