"""AWS Bedrock Converse API adapter for Hermes Agent. Talks to Bedrock through the native Converse API (boto3) instead of an OpenAI-compatible endpoint, so the AWS credential chain (IAM roles, SSO profiles, env vars, instance metadata), cross-region inference profiles, guardrails and control-plane model discovery all work without API keys. Same layout as ``anthropic_adapter.py``: all Bedrock-specific logic lives here; OpenAI-format messages/tools are converted to Converse format on the way in and responses are normalized back to OpenAI-shaped objects for the agent loop. Requires ``boto3`` (optional dependency). """ import base64 import json import logging import os 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__) # boto3 is not in the [all] extras; lazy_deps installs it on demand so the # provider works without baking boto3 into the base image. try: from tools.lazy_deps import ensure ensure("provider.bedrock", prompt=False) except Exception: pass # lazy_deps unavailable or install failed — let downstream imports surface the real error _bedrock_runtime_client_cache: Dict[str, Any] = {} _bedrock_control_client_cache: Dict[str, Any] = {} # Bedrock-hosted OpenAI GPT-5.x models are not exposed through the Converse # runtime; AWS serves them from the Bedrock Mantle OpenAI-compatible Responses # endpoint (https://bedrock-mantle..api.aws/openai/v1). Keep the # allowlist narrow so Converse-capable GPT-OSS models stay on the native path. BEDROCK_OPENAI_RESPONSES_MODEL_IDS: Tuple[str, ...] = ( "openai.gpt-5.5", "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) def _require_boto3(): """Import boto3, raising a clear error if not installed or too old. converse()/converse_stream() need boto3 >= 1.34.59; a system boto3 (e.g. Ubuntu's 1.34.46) can shadow the venv pin under editable installs. """ try: import boto3 except ImportError: raise ImportError( "The 'boto3' package is required for the AWS Bedrock provider. " "Install it with: pip install boto3\n" "Or install Hermes with Bedrock support: pip install -e '.[bedrock]'" ) try: version = tuple(int(x) for x in boto3.__version__.split(".")[:3]) except (AttributeError, ValueError): return boto3 # can't parse — don't block on version check if version < _MIN_BOTO3_VERSION: raise RuntimeError( f"boto3 {boto3.__version__} does not support converse_stream " f"(minimum 1.34.59 required). Upgrade with: " f"pip install --upgrade boto3" ) return boto3 def _cached_client(cache: Dict[str, Any], service: str, region: str): """Get or create a per-region boto3 client using the default credential chain.""" if region not in cache: cache[region] = _require_boto3().client(service, region_name=region) return cache[region] def _get_bedrock_runtime_client(region: str): return _cached_client(_bedrock_runtime_client_cache, "bedrock-runtime", region) def _get_bedrock_control_client(region: str): return _cached_client(_bedrock_control_client_cache, "bedrock", region) def reset_client_cache(): """Clear cached boto3 clients. Used in tests and profile switches.""" _bedrock_runtime_client_cache.clear() _bedrock_control_client_cache.clear() def invalidate_runtime_client(region: str) -> bool: """Evict one region's cached ``bedrock-runtime`` client (stale HTTP pool). Returns True if an entry was evicted. """ existed = region in _bedrock_runtime_client_cache _bedrock_runtime_client_cache.pop(region, None) return existed # --- Bedrock Mantle / OpenAI Responses support --- def is_openai_bedrock_model(model_id: str) -> bool: """True for Bedrock-hosted OpenAI models that require Mantle (GPT-OSS excluded).""" 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 Mantle-only OpenAI models, which control-plane discovery never lists.""" 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: """True for Bedrock Mantle endpoints (bare host or /openai[/v1] path).""" parsed = urlparse(str(base_url or "")) if not _BEDROCK_OPENAI_HOST_RE.match(parsed.hostname or ""): return False 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." ) # SigV4 must own Authorization: drop the SDK's placeholder bearer header # but keep every other SDK header (content-type, accept, request IDs). 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(credentials.get_frozen_credentials(), 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.""" 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. Real API keys (``AWS_BEARER_TOKEN_BEDROCK``) keep the SDK's bearer auth; the ``aws-sdk``/``no-key-required`` placeholders mean 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 --- # boto3 caches its HTTPS pool inside the client. A pooled connection killed # out from under us (NAT timeout, VPN flap, RST, proxy idle cull) surfaces as # botocore ConnectionClosedError / urllib3 ProtocolError, or as a bare # AssertionError from urllib3's internal pool-state checks. Retrying with the # same client reproduces the failure, so the fix is to evict the client. _STALE_LIB_MODULE_PREFIXES = ("urllib3.", "botocore.", "boto3.") def _traceback_frames_modules(exc: BaseException): """Yield the ``__name__`` of each frame in exc's traceback.""" tb = getattr(exc, "__traceback__", None) while tb is not None: yield tb.tb_frame.f_globals.get("__name__", "") or "" tb = tb.tb_next def _stale_error_types() -> tuple: """botocore + urllib3 transport-failure exception classes (best-effort import).""" types: list = [] try: from botocore.exceptions import ConnectionError as BotoConnectionError, HTTPClientError types += [BotoConnectionError, HTTPClientError] except ImportError: # pragma: no cover — botocore always present with boto3 pass try: from urllib3.exceptions import ConnectionError as Urllib3ConnectionError, NewConnectionError, ProtocolError types += [ProtocolError, NewConnectionError, Urllib3ConnectionError] except ImportError: # pragma: no cover pass return tuple(types) def is_stale_connection_error(exc: BaseException) -> bool: """True if ``exc`` indicates a dead/stale Bedrock HTTP connection. Matches botocore ConnectionError/HTTPClientError subclasses, urllib3 transport errors, and bare ``AssertionError``s raised from inside urllib3/botocore/boto3. Application-level AssertionErrors are NOT matched. """ if isinstance(exc, _stale_error_types()): return True if isinstance(exc, AssertionError): for module in _traceback_frames_modules(exc): if module.startswith(_STALE_LIB_MODULE_PREFIXES): return True return False def is_streaming_access_denied_error(exc: BaseException) -> bool: """True when IAM denied ``bedrock:InvokeModelWithResponseStream``. InvokeModel-only policies reject converse_stream() permanently, so callers should fall back to non-streaming converse() instead of retrying. Detection is message-based because the AnthropicBedrock SDK wraps the same AWS response in its own exception types but preserves the action name. """ msg = str(exc).lower() if "invokemodelwithresponsestream" not in msg: return False try: from botocore.exceptions import ClientError except ImportError: # pragma: no cover — botocore always present with boto3 ClientError = None # type: ignore[assignment] if ClientError is not None and isinstance(exc, ClientError): code = (getattr(exc, "response", None) or {}).get("Error", {}).get("Code", "") return code in ("AccessDeniedException", "UnauthorizedException") return "not authorized" in msg or "accessdenied" in msg # --- AWS credential detection --- # Priority order; the first group whose vars are ALL set names the auth source. _AWS_AUTH_ENV_CHAIN: Tuple[Tuple[str, ...], ...] = ( ("AWS_BEARER_TOKEN_BEDROCK",), # Bedrock bearer token ("AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"), # explicit IAM key pair ("AWS_PROFILE",), # named profile (SSO, assume-role) ("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI",), # ECS / CodeBuild ("AWS_WEB_IDENTITY_TOKEN_FILE",), # EKS IRSA ) def _boto3_chain_has_credentials() -> bool: """True if boto3's default chain resolves credentials (IMDS, task role, ...).""" try: import botocore.session credentials = botocore.session.get_session().get_credentials() if credentials is not None: resolved = credentials.get_frozen_credentials() return bool(resolved and resolved.access_key) except Exception: pass return False def resolve_aws_auth_env_var(env: Optional[Dict[str, str]] = None) -> Optional[str]: """Return the name of the active AWS auth source, or None. Env vars first (fast, no I/O), then ``"iam-role"`` if boto3's chain resolves implicit credentials (EC2 IMDS, ECS task role, Lambda). """ env = env if env is not None else os.environ for group in _AWS_AUTH_ENV_CHAIN: if all(env.get(var, "").strip() for var in group): return group[0] return "iam-role" if _boto3_chain_has_credentials() else None def has_aws_credentials(env: Optional[Dict[str, str]] = None) -> bool: """True if any AWS credential source (env vars or boto3 chain) is detected.""" return resolve_aws_auth_env_var(env) is not None or _boto3_chain_has_credentials() def resolve_bedrock_region(env: Optional[Dict[str, str]] = None) -> str: """AWS_REGION → AWS_DEFAULT_REGION → botocore configured region → us-east-1. The botocore fallback matters for EU/AP users who set the region in ~/.aws/config via a named profile; without it discovery returns us.* IDs. """ env = env if env is not None else os.environ explicit = env.get("AWS_REGION", "").strip() or env.get("AWS_DEFAULT_REGION", "").strip() if explicit: return explicit try: import botocore.session region = botocore.session.get_session().get_config_variable("region") if region: return region except Exception: pass return "us-east-1" def resolve_bedrock_runtime_region(config: Optional[Dict[str, Any]] = None) -> str: """``bedrock.region`` from config.yaml, else :func:`resolve_bedrock_region`. Matches ``hermes_cli/runtime_provider.py``. Every non-runtime Bedrock endpoint (auxiliary clients, picker discovery) must use this, otherwise auxiliary calls can leave the primary runtime's region when the config and ambient AWS env/profile disagree. Pass *config* to avoid a disk read. """ if config is None: try: from hermes_cli.config import load_config_readonly config = load_config_readonly() except Exception: config = {} cfg_region = str(((config or {}).get("bedrock") or {}).get("region") or "").strip() return cfg_region or resolve_bedrock_region() def bedrock_model_ids_or_none() -> Optional[List[str]]: """Live-discover Bedrock model IDs; None on failure/empty so callers use the static list.""" try: discovered = discover_bedrock_models(resolve_bedrock_runtime_region()) if discovered: return merge_bedrock_openai_model_ids([m["id"] for m in discovered]) except Exception: pass return None # --- Tool-calling / prompt-cache capability detection --- # Models known to reject toolConfig with a ValidationException. Conservative: # unknown models are assumed to support tools; add here when one fails. _NON_TOOL_CALLING_PATTERNS = [ "deepseek.r1", # DeepSeek R1 — reasoning only "deepseek-r1", # Alternate ID format "stability.", # Image generation "cohere.embed", # Embeddings "amazon.titan-embed", # Embeddings ] def _model_supports_tool_use(model_id: str) -> bool: """False for denylisted models; unknown models default to True.""" model_lower = model_id.lower() return not any(pattern in model_lower for pattern in _NON_TOOL_CALLING_PATTERNS) # Converse API cachePoint allowlist — inverted policy vs tools: an unsupported # model rejects cachePoint with a ValidationException, so unknown models get # NO cache markers until confirmed. Claude normally uses the AnthropicBedrock # SDK path and only reaches build_converse_kwargs under bearer-token auth. # Ref: https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-caching.html _CACHE_POINT_PATTERNS = [ "anthropic.claude", # bearer-token fallback path "amazon.nova", ] def _model_supports_prompt_cache(model_id: str) -> bool: """True if the model accepts a Converse API cachePoint block.""" model_lower = model_id.lower() return any(pattern in model_lower for pattern in _CACHE_POINT_PATTERNS) # --- Server-verdict cachePoint suppression --- # Bedrock's real cachePoint rule is per-family AND per-field (Nova accepts it # in system/messages but hard-fails on toolConfig.tools), and any static table # drifts as AWS ships new families — failing 100% of turns with no workaround. # So when Bedrock names a placement as unpermitted we record that verdict, # drop the marker from that placement for the rest of the process, and retry # the rejected request once without it. CACHE_POINT_PLACEMENTS = ("tools", "system", "messages") # model_id (lowercased) → placements Bedrock has rejected this process. _CACHE_POINT_REJECTIONS: Dict[str, set] = {} # "#/toolConfig/tools/18: extraneous key [cachePoint] is not permitted" _CACHE_POINT_PATH_PATTERN = re.compile(r"#/(?P[A-Za-z0-9_./\[\]-]*)", re.IGNORECASE) def cache_point_rejection_placement(exc: BaseException) -> Optional[str]: """Return the Converse section whose cachePoint Bedrock refused, or None. Message-based on purpose: the JSON pointer in the ValidationException is the only thing that says *which* section was rejected, and the same wording arrives both as a raw botocore ClientError and wrapped by SDKs. An unlocalisable rejection maps to "tools" — the only placement any supported family is known to refuse while accepting the others. """ msg = str(exc) lowered = msg.lower() if "cachepoint" not in lowered: return None if "not permitted" not in lowered and "extraneous" not in lowered: return None match = _CACHE_POINT_PATH_PATTERN.search(msg) path = (match.group("path") if match else "").lower() if "toolconfig" in path or "tools" in path: return "tools" if "system" in path: return "system" if "messages" in path: return "messages" return "tools" def note_cache_point_rejection(model_id: str, placement: str) -> None: """Record that ``model_id`` refuses cachePoint blocks in ``placement``.""" if placement not in CACHE_POINT_PLACEMENTS: return _CACHE_POINT_REJECTIONS.setdefault(model_id.lower(), set()).add(placement) def cache_point_allowed(model_id: str, placement: str) -> bool: """False once Bedrock has refused this placement for this model.""" return placement not in _CACHE_POINT_REJECTIONS.get(model_id.lower(), ()) def reset_cache_point_rejections() -> None: """Clear recorded cachePoint rejections. Used in tests.""" _CACHE_POINT_REJECTIONS.clear() def _is_cache_point_block(block: Any) -> bool: return isinstance(block, dict) and set(block.keys()) == {"cachePoint"} def _without_cache_points(blocks: Any) -> Optional[list]: """Return ``blocks`` minus cachePoint entries, or None if not a list / nothing removed.""" if not isinstance(blocks, list): return None cleaned = [b for b in blocks if not _is_cache_point_block(b)] return None if len(cleaned) == len(blocks) else cleaned def strip_cache_points(kwargs: Dict[str, Any], placement: str) -> Dict[str, Any]: """Copy of Converse kwargs with ``placement``'s cachePoint removed. Returns the input unchanged (same object) when there was nothing to strip, which is what callers use to decide a retry cannot help. """ if placement == "system": cleaned = _without_cache_points(kwargs.get("system")) return kwargs if cleaned is None else {**kwargs, "system": cleaned} if placement == "tools": tool_config = kwargs.get("toolConfig") cleaned = _without_cache_points((tool_config or {}).get("tools")) if cleaned is None: return kwargs return {**kwargs, "toolConfig": {**tool_config, "tools": cleaned}} if placement == "messages": messages = kwargs.get("messages") if not isinstance(messages, list): return kwargs changed = False cleaned_messages = [] for msg in messages: content = _without_cache_points(msg.get("content") if isinstance(msg, dict) else None) if content is not None: changed = True cleaned_messages.append({**msg, "content": content}) else: cleaned_messages.append(msg) return {**kwargs, "messages": cleaned_messages} if changed else kwargs return kwargs def recover_from_cache_point_rejection(exc: BaseException, kwargs: Dict[str, Any]) -> Optional[Dict[str, Any]]: """Record Bedrock's cachePoint verdict and return retry kwargs, or None. None means the error was not a cachePoint rejection, or the marker was already absent — retrying cannot change the outcome; caller must re-raise. """ placement = cache_point_rejection_placement(exc) if placement is None: return None retry_kwargs = strip_cache_points(kwargs, placement) if retry_kwargs is kwargs: return None model_id = str(kwargs.get("modelId", "")) note_cache_point_rejection(model_id, placement) logger.warning( "bedrock: %s rejected a cachePoint block in %s — dropping that cache " "marker for this model and retrying. Prompt caching stays active for " "the remaining sections.", model_id or "model", placement, ) return retry_kwargs _REGIONAL_PREFIXES = ("global.", "us.", "eu.", "apac.", "ap.", "au.", "jp.", "ca.", "sa.", "me.", "af.") def is_anthropic_bedrock_model(model_id: str) -> bool: """True for Claude on Bedrock (``anthropic.claude-*`` with any regional prefix). These use the AnthropicBedrock SDK path for full feature parity (prompt caching, thinking budgets); non-Claude models use the Converse path. """ model_lower = model_id.lower() for prefix in _REGIONAL_PREFIXES: if model_lower.startswith(prefix): model_lower = model_lower[len(prefix):] break return model_lower.startswith("anthropic.claude") # --- Message format conversion: OpenAI → Bedrock Converse --- def convert_tools_to_converse(tools: List[Dict]) -> List[Dict]: """OpenAI ``{"function": {...}}`` tool defs → Converse ``{"toolSpec": {...}}``.""" result = [] for t in tools or []: fn = t.get("function", {}) result.append({ "toolSpec": { "name": fn.get("name", ""), "description": fn.get("description", ""), "inputSchema": {"json": fn.get("parameters", {"type": "object", "properties": {}})}, } }) return result # Converse rejects empty OR whitespace-only text blocks ("text content blocks # must contain non-whitespace text"), so the placeholder must be non-whitespace. _EMPTY_TEXT_PLACEHOLDER = "(empty)" def _safe_text(text) -> str: """Return ``text`` if it has non-whitespace content, else the placeholder (None/non-str ok).""" if text is None: return _EMPTY_TEXT_PLACEHOLDER if not isinstance(text, str): text = str(text) return text if text.strip() else _EMPTY_TEXT_PLACEHOLDER def _image_block_from_data_url(url: str) -> Dict: """``data:;base64,...`` → Converse image block with RAW bytes. boto3 base64-encodes at the wire layer, so passing the base64 string through double-encodes and Bedrock rejects it ("Failed to sanitize image"). """ header, _, data = url.partition(",") media_type = "image/jpeg" if header.startswith("data:"): mime_part = header[5:].split(";")[0] if mime_part: media_type = mime_part try: raw_bytes = base64.b64decode(data) except Exception: raw_bytes = data.encode("utf-8") return { "image": { "format": media_type.split("/")[-1] if "/" in media_type else "jpeg", "source": {"bytes": raw_bytes}, } } def _convert_content_to_converse(content) -> List[Dict]: """OpenAI message content (str or parts list) → Converse content blocks. Empty/whitespace-only text becomes the placeholder; remote image URLs are not supported by Converse and are included as a text reference instead. """ if not isinstance(content, list): return [{"text": _safe_text(content)}] blocks = [] for part in content: if isinstance(part, str): blocks.append({"text": _safe_text(part)}) continue if not isinstance(part, dict): continue part_type = part.get("type", "") if part_type == "text": blocks.append({"text": _safe_text(part.get("text", ""))}) elif part_type == "image_url": image_url = part.get("image_url", {}) url = image_url.get("url", "") if isinstance(image_url, dict) else "" if url.startswith("data:"): blocks.append(_image_block_from_data_url(url)) else: blocks.append({"text": f"[Image: {url}]"}) return blocks if blocks else [{"text": _EMPTY_TEXT_PLACEHOLDER}] def _system_blocks(content) -> List[Dict]: """System content → text blocks; blank parts are dropped, not placeholder-filled.""" if isinstance(content, str): return [{"text": content}] if content.strip() else [] blocks: List[Dict] = [] if isinstance(content, list): for part in content: if isinstance(part, dict) and part.get("type") == "text": text = part.get("text", "") if isinstance(text, str) and text.strip(): blocks.append({"text": text}) elif isinstance(part, str) and part.strip(): blocks.append({"text": part}) return blocks def _tool_use_block(tool_use_id, name, input_dict) -> Dict: return {"toolUse": {"toolUseId": tool_use_id, "name": name, "input": input_dict}} def _replay_ordered_blocks(ordered_blocks: List) -> List[Dict]: """Rebuild the exact Bedrock block sequence captured at normalization time. Redacted reasoning bytes are stored base64-encoded (JSON-safe sidecar) and decoded here; undecodable entries are skipped. """ content_blocks: List[Dict] = [] for block in ordered_blocks: if not isinstance(block, dict): continue if "text" in block and isinstance(block["text"], str): content_blocks.append({"text": block["text"]}) elif "reasoningContent" in block: reasoning = block["reasoningContent"] if not isinstance(reasoning, dict): continue replay = {} if isinstance(reasoning.get("text"), str): replay["text"] = reasoning["text"] encoded = reasoning.get("redactedContentBase64") if isinstance(encoded, str) and encoded: try: replay["redactedContent"] = base64.b64decode(encoded, validate=True) except (ValueError, TypeError): continue if replay: content_blocks.append({"reasoningContent": replay}) elif "toolUse" in block and isinstance(block["toolUse"], dict): tu = block["toolUse"] content_blocks.append(_tool_use_block(tu.get("toolUseId", ""), tu.get("name", ""), tu.get("input", {}))) return content_blocks def _assistant_blocks(msg: Dict, content) -> List[Dict]: """Assistant message → Converse blocks. An ordered ``bedrock_content_blocks`` sidecar is authoritative and replayed verbatim. Otherwise: redacted thinking from ``reasoning_details`` (so opaque encrypted reasoning round-trips byte-for-byte), then text, then tool calls. """ ordered_blocks = msg.get("bedrock_content_blocks") if isinstance(ordered_blocks, list) and ordered_blocks: content_blocks = _replay_ordered_blocks(ordered_blocks) if content_blocks: return content_blocks content_blocks = [] for detail in (msg.get("reasoning_details") or []): if not isinstance(detail, dict) or detail.get("type") != "redacted_thinking": continue encoded = detail.get("data") or detail.get("redactedContentBase64") if not isinstance(encoded, str) or not encoded: continue try: redacted = base64.b64decode(encoded, validate=True) except (ValueError, TypeError): continue content_blocks.append({"reasoningContent": {"redactedContent": redacted}}) if isinstance(content, str) and content.strip(): content_blocks.append({"text": content}) elif isinstance(content, list): content_blocks.extend(_convert_content_to_converse(content)) for tc in (msg.get("tool_calls", []) or []): fn = tc.get("function", {}) args_str = fn.get("arguments", "{}") try: args_dict = json.loads(args_str) if isinstance(args_str, str) else args_str except (json.JSONDecodeError, TypeError): args_dict = {} content_blocks.append(_tool_use_block(tc.get("id", ""), fn.get("name", ""), args_dict)) return content_blocks def _append_turn(converse_msgs: List[Dict], role: str, blocks: List[Dict]) -> None: """Append blocks as a new turn, merging into the previous turn of the same role (strict alternation).""" if converse_msgs and converse_msgs[-1]["role"] == role: converse_msgs[-1]["content"].extend(blocks) else: converse_msgs.append({"role": role, "content": blocks}) def convert_messages_to_converse(messages: List[Dict]) -> Tuple[Optional[List[Dict]], List[Dict]]: """OpenAI messages → ``(system_blocks_or_None, converse_messages)``. System messages become the system prompt; tool results become ``toolResult`` blocks in a user turn. Converse requires strict user/assistant alternation with a user turn first and last, so same-role neighbours are merged and placeholder user turns are inserted at the ends. """ system_blocks: List[Dict] = [] converse_msgs: List[Dict] = [] for msg in messages: role = msg.get("role", "") content = msg.get("content") if role == "system": system_blocks.extend(_system_blocks(content)) elif role == "tool": result_content = content if isinstance(content, str) else json.dumps(content) _append_turn(converse_msgs, "user", [{ "toolResult": { "toolUseId": msg.get("tool_call_id", ""), "content": [{"text": _safe_text(result_content)}], } }]) elif role == "assistant": content_blocks = _assistant_blocks(msg, content) or [{"text": _EMPTY_TEXT_PLACEHOLDER}] _append_turn(converse_msgs, "assistant", content_blocks) elif role == "user": _append_turn(converse_msgs, "user", _convert_content_to_converse(content)) if converse_msgs and converse_msgs[0]["role"] != "user": converse_msgs.insert(0, {"role": "user", "content": [{"text": _EMPTY_TEXT_PLACEHOLDER}]}) if converse_msgs and converse_msgs[-1]["role"] != "user": converse_msgs.append({"role": "user", "content": [{"text": _EMPTY_TEXT_PLACEHOLDER}]}) return (system_blocks if system_blocks else None, converse_msgs) # --- Response format conversion: Bedrock Converse → OpenAI --- # Bedrock stopReason → OpenAI finish_reason (unknown → "stop"). _STOP_REASON_TO_FINISH_REASON = { "end_turn": "stop", "stop_sequence": "stop", "tool_use": "tool_calls", "max_tokens": "length", "content_filtered": "content_filter", "guardrail_intervened": "content_filter", } def _encode_redacted(redacted) -> Optional[str]: """Redacted reasoning payload → base64 str (bytes encoded, str passed through, else None).""" if isinstance(redacted, (bytes, bytearray)): return base64.b64encode(bytes(redacted)).decode("ascii") if isinstance(redacted, str): return redacted return None def _tool_call_ns(tool_use_id: str, name: str, input_dict) -> SimpleNamespace: return SimpleNamespace( id=tool_use_id, type="function", function=SimpleNamespace(name=name, arguments=json.dumps(input_dict)), ) def _build_openai_response( text_parts: List[str], reasoning_parts: List[str], reasoning_details: List[Dict[str, Any]], tool_calls: List[SimpleNamespace], ordered_blocks: List[Dict[str, Any]], usage_data: Dict[str, int], stop_reason: str, model: str, ) -> SimpleNamespace: """Assemble the OpenAI-shaped response shared by the sync and streaming paths. Converse's inputTokens EXCLUDES cache read/write tokens (OpenAI's prompt_tokens includes them), so they are added back here and the Anthropic-named cache fields are surfaced for downstream normalize_usage(). """ msg = SimpleNamespace( role="assistant", content="\n".join(text_parts) if text_parts else None, tool_calls=tool_calls if tool_calls else None, reasoning_content="\n\n".join(reasoning_parts) if reasoning_parts else None, reasoning_details=reasoning_details or None, bedrock_content_blocks=ordered_blocks or None, ) input_tokens = usage_data.get("inputTokens", 0) cache_read_tokens = usage_data.get("cacheReadInputTokens", 0) cache_write_tokens = usage_data.get("cacheWriteInputTokens", 0) output_tokens = usage_data.get("outputTokens", 0) usage = SimpleNamespace( prompt_tokens=input_tokens + cache_read_tokens + cache_write_tokens, completion_tokens=output_tokens, total_tokens=input_tokens + cache_read_tokens + cache_write_tokens + output_tokens, cache_read_input_tokens=cache_read_tokens, cache_creation_input_tokens=cache_write_tokens, ) finish_reason = _STOP_REASON_TO_FINISH_REASON.get(stop_reason, "stop") if tool_calls and finish_reason == "stop": finish_reason = "tool_calls" return SimpleNamespace( choices=[SimpleNamespace(index=0, message=msg, finish_reason=finish_reason)], usage=usage, model=model, ) def normalize_converse_response(response: Dict) -> SimpleNamespace: """Bedrock Converse response → OpenAI ``ChatCompletion``-shaped SimpleNamespace. Exposes ``.choices[0].message.{content,tool_calls,reasoning_content, reasoning_details,bedrock_content_blocks}``, ``.choices[0].finish_reason`` and ``.usage``. """ content_blocks = response.get("output", {}).get("message", {}).get("content", []) text_parts = [] reasoning_parts = [] reasoning_details = [] ordered_blocks = [] tool_calls = [] for block in content_blocks: if "text" in block: text_parts.append(block["text"]) ordered_blocks.append({"text": block["text"]}) elif "reasoningContent" in block: reasoning = block["reasoningContent"] if not isinstance(reasoning, dict): continue thinking_text = reasoning.get("text", "") encoded = _encode_redacted(reasoning.get("redactedContent")) ordered_reasoning = {} if thinking_text: reasoning_parts.append(str(thinking_text)) ordered_reasoning["text"] = str(thinking_text) if encoded: reasoning_details.append({"type": "redacted_thinking", "data": encoded}) ordered_reasoning["redactedContentBase64"] = encoded if ordered_reasoning: ordered_blocks.append({"reasoningContent": ordered_reasoning}) elif "toolUse" in block: tu = block["toolUse"] tool_use_id, name, tool_input = tu.get("toolUseId", ""), tu.get("name", ""), tu.get("input", {}) ordered_blocks.append(_tool_use_block(tool_use_id, name, tool_input)) tool_calls.append(_tool_call_ns(tool_use_id, name, tool_input)) return _build_openai_response( text_parts, reasoning_parts, reasoning_details, tool_calls, ordered_blocks, response.get("usage", {}), response.get("stopReason", "end_turn"), response.get("modelId", ""), ) # --- Streaming response conversion --- def normalize_converse_stream_events(event_stream) -> SimpleNamespace: """Consume a ConverseStream event stream (no callbacks) → same shape as ``normalize_converse_response()``.""" return stream_converse_with_callbacks(event_stream) def stream_converse_with_callbacks( event_stream, on_text_delta=None, on_tool_start=None, on_reasoning_delta=None, on_interrupt_check=None, on_event=None, ) -> SimpleNamespace: """Process a boto3 ``converse_stream()`` response with real-time callbacks. ``on_text_delta`` only fires while no toolUse block has been seen (same semantics as the Anthropic/chat_completions paths). ``on_interrupt_check`` runs per event; True stops streaming. ``on_event`` is a wire-level liveness signal fired for EVERY event before any branching (so a watchdog can tell "still receiving" from "wedged"); its exceptions are swallowed so it can never abort the stream. Returns the ``normalize_converse_response()`` shape. """ text_parts: List[str] = [] reasoning_parts: List[str] = [] reasoning_details: List[Dict[str, Any]] = [] tool_calls: List[SimpleNamespace] = [] stream_blocks: Dict[int, Dict[str, Any]] = {} current_block_index: Optional[int] = None current_tool: Optional[Dict] = None current_text_buffer: List[str] = [] has_tool_use = False stop_reason = "end_turn" usage_data: Dict[str, int] = {} def _current_block(default: Dict[str, Any]) -> Dict[str, Any]: idx = current_block_index if current_block_index is not None else len(stream_blocks) return stream_blocks.setdefault(idx, default) def _flush_text() -> None: nonlocal current_text_buffer if current_text_buffer: text_parts.append("".join(current_text_buffer)) current_text_buffer = [] for event in event_stream.get("stream", []): if on_event is not None: try: on_event() except Exception: pass if on_interrupt_check and on_interrupt_check(): break if "contentBlockStart" in event: start_event = event["contentBlockStart"] current_block_index = start_event.get("contentBlockIndex", len(stream_blocks)) start = start_event.get("start", {}) if "toolUse" in start: has_tool_use = True _flush_text() current_tool = { "toolUseId": start["toolUse"].get("toolUseId", ""), "name": start["toolUse"].get("name", ""), "input_json": "", } stream_blocks[current_block_index] = _tool_use_block(current_tool["toolUseId"], current_tool["name"], {}) if on_tool_start: on_tool_start(current_tool["name"]) elif "contentBlockDelta" in event: delta = event["contentBlockDelta"].get("delta", {}) if "text" in delta: text = delta["text"] block = _current_block({"text": ""}) block["text"] = block.get("text", "") + text current_text_buffer.append(text) if on_text_delta and not has_tool_use: on_text_delta(text) elif "toolUse" in delta: if current_tool is not None: current_tool["input_json"] += delta["toolUse"].get("input", "") elif "reasoningContent" in delta: reasoning = delta["reasoningContent"] if isinstance(reasoning, dict): thinking_text = reasoning.get("text", "") if thinking_text: reasoning_parts.append(str(thinking_text)) if on_reasoning_delta: on_reasoning_delta(thinking_text) block = _current_block({"reasoningContent": {}}) block.setdefault("reasoningContent", {})["text"] = block["reasoningContent"].get("text", "") + str(thinking_text) encoded = _encode_redacted(reasoning.get("redactedContent")) if encoded: reasoning_details.append({"type": "redacted_thinking", "data": encoded}) block = _current_block({"reasoningContent": {}}) block.setdefault("reasoningContent", {})["redactedContentBase64"] = encoded elif "contentBlockStop" in event: if current_tool is not None: try: input_dict = json.loads(current_tool["input_json"]) if current_tool["input_json"] else {} except (json.JSONDecodeError, TypeError): input_dict = {} tool_calls.append(_tool_call_ns(current_tool["toolUseId"], current_tool["name"], input_dict)) if current_block_index is not None and current_block_index in stream_blocks: stream_blocks[current_block_index]["toolUse"]["input"] = input_dict current_tool = None else: _flush_text() elif "messageStop" in event: stop_reason = event["messageStop"].get("stopReason", "end_turn") elif "metadata" in event: meta_usage = event["metadata"].get("usage", {}) usage_data = { "inputTokens": meta_usage.get("inputTokens", 0), "outputTokens": meta_usage.get("outputTokens", 0), "cacheReadInputTokens": meta_usage.get("cacheReadInputTokens", 0), "cacheWriteInputTokens": meta_usage.get("cacheWriteInputTokens", 0), } _flush_text() return _build_openai_response( text_parts, reasoning_parts, reasoning_details, tool_calls, [stream_blocks[i] for i in sorted(stream_blocks)], usage_data, stop_reason, "", ) # --- High-level API: call Bedrock Converse --- def _cache_point() -> Dict[str, Any]: return {"cachePoint": {"type": "default"}} def build_converse_kwargs( model: str, messages: List[Dict], tools: Optional[List[Dict]] = None, max_tokens: Optional[int] = 4096, temperature: Optional[float] = None, top_p: Optional[float] = None, stop_sequences: Optional[List[str]] = None, guardrail_config: Optional[Dict] = None, ) -> Dict[str, Any]: """Build kwargs for ``bedrock-runtime.converse()`` / ``converse_stream()``. ``max_tokens=None`` omits ``inferenceConfig.maxTokens`` so Bedrock uses the model's maximum output (callers wanting the full budget, e.g. uncapped auxiliary vision calls, pass None explicitly; default stays 4096). cachePoint markers go on system, tools and the second-newest message (so the marker survives as only the tail grows — mirrors the Anthropic system_and_3 strategy) — each only if the model supports caching and Bedrock has not rejected that placement. """ system_prompt, converse_messages = convert_messages_to_converse(messages) cache_enabled = _model_supports_prompt_cache(model) inference_config: Dict[str, Any] = {} if max_tokens is not None: inference_config["maxTokens"] = max_tokens kwargs: Dict[str, Any] = { "modelId": model, "messages": converse_messages, "inferenceConfig": inference_config, } if system_prompt: if cache_enabled and cache_point_allowed(model, "system"): system_prompt = system_prompt + [_cache_point()] kwargs["system"] = system_prompt from agent.anthropic_adapter import _forbids_sampling_params if not _forbids_sampling_params(model): if temperature is not None: inference_config["temperature"] = temperature if top_p is not None: inference_config["topP"] = top_p if stop_sequences: inference_config["stopSequences"] = stop_sequences if tools: converse_tools = convert_tools_to_converse(tools) if converse_tools: # Non-tool-calling models (e.g. DeepSeek R1) reject toolConfig with a # ValidationException → retry loop → failure. Strip tools and warn. if _model_supports_tool_use(model): if cache_enabled and cache_point_allowed(model, "tools"): converse_tools = converse_tools + [_cache_point()] kwargs["toolConfig"] = {"tools": converse_tools} else: logger.warning( "Model %s does not support tool calling — tools stripped. " "The agent will operate in text-only mode.", model ) if ( cache_enabled and cache_point_allowed(model, "messages") and len(converse_messages) >= 2 ): content = converse_messages[-2].get("content") if isinstance(content, list) and content: content.append(_cache_point()) if guardrail_config: kwargs["guardrailConfig"] = guardrail_config if not inference_config: del kwargs["inferenceConfig"] # optional on the wire; don't send {} return kwargs def call_converse( region: str, model: str, messages: List[Dict], tools: Optional[List[Dict]] = None, max_tokens: Optional[int] = 4096, temperature: Optional[float] = None, top_p: Optional[float] = None, stop_sequences: Optional[List[str]] = None, guardrail_config: Optional[Dict] = None, ) -> SimpleNamespace: """Non-streaming Converse call → OpenAI-compatible response. Retries once without the rejected cachePoint placement; evicts the cached client on stale-connection errors before re-raising. """ client = _get_bedrock_runtime_client(region) kwargs = build_converse_kwargs( model, messages, tools, max_tokens, temperature, top_p, stop_sequences, guardrail_config, ) try: response = client.converse(**kwargs) except Exception as exc: retry_kwargs = recover_from_cache_point_rejection(exc, kwargs) if retry_kwargs is not None: return normalize_converse_response(client.converse(**retry_kwargs)) if is_stale_connection_error(exc): logger.warning( "bedrock: stale-connection error on converse(region=%s, model=%s): " "%s — evicting cached client so the next call reconnects.", region, model, type(exc).__name__, ) invalidate_runtime_client(region) raise return normalize_converse_response(response) # --- Model discovery --- _discovery_cache: Dict[str, Any] = {} _DISCOVERY_CACHE_TTL_SECONDS = 3600 def reset_discovery_cache(): """Clear the model discovery cache. Used in tests.""" _discovery_cache.clear() def _list_foundation_models(client, filter_set: set, models: List[Dict[str, Any]]) -> None: """Append active, streaming-capable, text-output foundation models (optionally provider-filtered).""" for summary in client.list_foundation_models().get("modelSummaries", []): model_id = (summary.get("modelId") or "").strip() if not model_id: continue if filter_set: provider_name = (summary.get("providerName") or "").lower() model_prefix = model_id.split(".")[0].lower() if "." in model_id else "" if provider_name not in filter_set and model_prefix not in filter_set: continue if summary.get("modelLifecycle", {}).get("status", "").upper() != "ACTIVE": continue if not summary.get("responseStreamingSupported", False): continue output_mods = summary.get("outputModalities", []) if "TEXT" not in output_mods: continue models.append({ "id": model_id, "name": (summary.get("modelName") or model_id).strip(), "provider": (summary.get("providerName") or "").strip(), "input_modalities": summary.get("inputModalities", []), "output_modalities": output_mods, "streaming": True, }) def _list_inference_profiles(client, filter_set: set, models: List[Dict[str, Any]]) -> None: """Append active cross-region inference profiles whose IDs are not already present (paginated).""" profiles = [] next_token = None while True: kwargs = {"nextToken": next_token} if next_token else {} response = client.list_inference_profiles(**kwargs) profiles.extend(response.get("inferenceProfileSummaries", [])) next_token = response.get("nextToken") if not next_token: break seen_ids = {m["id"].lower() for m in models} for profile in profiles: profile_id = (profile.get("inferenceProfileId") or "").strip() if not profile_id or profile.get("status") != "ACTIVE" or profile_id.lower() in seen_ids: continue if filter_set and not any( _extract_provider_from_arn(m.get("modelArn", "")).lower() in filter_set for m in profile.get("models", []) ): continue models.append({ "id": profile_id, "name": (profile.get("inferenceProfileName") or profile_id).strip(), "provider": "inference-profile", "input_modalities": ["TEXT"], "output_modalities": ["TEXT"], "streaming": True, }) seen_ids.add(profile_id.lower()) def discover_bedrock_models(region: str, provider_filter: Optional[List[str]] = None) -> List[Dict[str, Any]]: """Discover Bedrock foundation models + inference profiles (cached 1h per region/filter). Each entry has ``id``, ``name``, ``provider`` ("inference-profile" for profiles), ``input_modalities``, ``output_modalities``, ``streaming``. Sorted with ``global.`` cross-region profiles first, then by name. Returns [] when the client cannot be built. """ import time cache_key = f"{region}:{','.join(sorted(provider_filter or []))}" cached = _discovery_cache.get(cache_key) if cached and (time.time() - cached["timestamp"]) < _DISCOVERY_CACHE_TTL_SECONDS: return cached["models"] try: client = _get_bedrock_control_client(region) except Exception as e: logger.warning("Failed to create Bedrock client for model discovery: %s", e) return [] models = [] filter_set = {f.lower() for f in (provider_filter or [])} try: _list_foundation_models(client, filter_set, models) except Exception as e: logger.warning("Failed to list Bedrock foundation models: %s", e) try: _list_inference_profiles(client, filter_set, models) except Exception as e: logger.debug("Skipping inference profile discovery: %s", e) models.sort(key=lambda m: ( 0 if m["id"].startswith("global.") else 1, m["name"].lower(), )) _discovery_cache[cache_key] = {"timestamp": time.time(), "models": models} return models def _extract_provider_from_arn(arn: str) -> str: """``arn:aws:bedrock:...:foundation-model/anthropic.claude-v2`` → ``"anthropic"``.""" match = re.search(r"foundation-model/([^.]+)", arn) return match.group(1) if match else "" # --- Bedrock model context lengths --- # Static fallback table for when the live probe is unavailable; used by # agent/model_metadata.py. Keys are matched by longest substring, so versioned # entries (opus-4-6/4-7/4-8) win over the generic "anthropic.claude-opus-4". BEDROCK_CONTEXT_LENGTHS: Dict[str, int] = { # Anthropic Claude (per Anthropic's models overview). Fable / Sonnet 5 / # Opus 4.8-4.6 / Sonnet 4.6 are 1M GA; Sonnet 4.5 / Sonnet 4 lost their 1M # beta and are 200K; Haiku 4.5 is 200K. The 1M entries must match # agent/model_metadata.py DEFAULT_CONTEXT_LENGTHS or context compresses early. "anthropic.claude-fable-5": 1_000_000, "anthropic.claude-fable": 1_000_000, "anthropic.claude-sonnet-5": 1_000_000, "anthropic.claude-opus-4-8": 1_000_000, "anthropic.claude-opus-4-7": 1_000_000, "anthropic.claude-opus-4-6": 1_000_000, "anthropic.claude-sonnet-4-6": 1_000_000, "anthropic.claude-sonnet-4-5": 200_000, "anthropic.claude-haiku-4-5": 200_000, "anthropic.claude-opus-4": 200_000, "anthropic.claude-sonnet-4": 200_000, "anthropic.claude-3-5-sonnet": 200_000, "anthropic.claude-3-5-haiku": 200_000, "anthropic.claude-3-opus": 200_000, "anthropic.claude-3-sonnet": 200_000, "anthropic.claude-3-haiku": 200_000, # Amazon Nova "amazon.nova-pro": 300_000, "amazon.nova-lite": 300_000, "amazon.nova-micro": 128_000, # Meta Llama "meta.llama4-maverick": 128_000, "meta.llama4-scout": 128_000, "meta.llama3-3-70b-instruct": 128_000, # Mistral "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 BEDROCK_DEFAULT_CONTEXT_LENGTH = 128_000 # Probe padding targets (tokens). Tiered rather than one giant request because # a wildly oversized payload (5M tokens) yields an opaque InternalServerException # instead of a clean ValidationException, and stepping up discovers 2M+ # windows without over-padding smaller ones. The error reports the true maximum. _BEDROCK_PROBE_TIERS = (1_300_000, 2_200_000) _WORDS_PER_TOKEN = 0.9 # conservative: ensures the padded prompt clears the tier def _static_bedrock_context_length(model_id: str) -> int: """Longest-substring match against the static table (versioned IDs resolve correctly).""" model_lower = model_id.lower() matches = [key for key in BEDROCK_CONTEXT_LENGTHS if key in model_lower] return BEDROCK_CONTEXT_LENGTHS[max(matches, key=len)] if matches else BEDROCK_DEFAULT_CONTEXT_LENGTH def probe_bedrock_context_length(model_id: str, region: str) -> Optional[int]: """Discover a model's real context window by provoking a length error. No Bedrock metadata API exposes the window; the only authoritative source is the ValidationException for an oversized prompt ("prompt is too long: 1300032 tokens > 1000000 maximum"). Length validation happens before inference, so the probe is rejected immediately and costs nothing. If a tier is accepted, that tier is returned as a safe lower bound. Returns None if the probe could not run (no credentials, network error, no parseable limit) so the caller falls back to the static table. """ try: from agent.model_metadata import parse_context_limit_from_error except ImportError: # pragma: no cover — same package return None try: client = _get_bedrock_runtime_client(region) except Exception as exc: # boto3 missing / credential resolution failure logger.debug("Bedrock context probe skipped for %s: %s", model_id, exc) return None last_error = "" for tier_tokens in _BEDROCK_PROBE_TIERS: oversized = "data " * int(tier_tokens / _WORDS_PER_TOKEN) try: client.converse( modelId=model_id, messages=[{"role": "user", "content": [{"text": oversized}]}], inferenceConfig={"maxTokens": 8}, ) logger.debug( "Bedrock context probe for %s accepted ~%s-token prompt; " "window is at least that", model_id, f"{tier_tokens:,}", ) return tier_tokens except Exception as exc: last_error = str(exc) limit = parse_context_limit_from_error(last_error) if limit and limit >= 1024: logger.info( "Probed Bedrock context window for %s: %s tokens", model_id, f"{limit:,}", ) return limit # Opaque server error / auth / throttle at this tier — try the next. logger.debug( "Bedrock context probe for %s returned no parseable limit: %s", model_id, last_error[:200], ) return None def get_bedrock_context_length(model_id: str, region: str = "", probe: bool = True) -> int: """Context window: live probe (if ``probe`` and ``region``) → static table → default. The static table is a fallback only: AWS ships new versions faster than the table tracks, and a stale substring match silently caps the window (e.g. a 1M Opus pinned to 200K via "opus-4"). ``probe=False`` or an empty region skips the network call for offline/display paths. """ if probe and region: probed = probe_bedrock_context_length(model_id, region) if probed: return probed return _static_bedrock_context_length(model_id)