From 306958d6e6ee08cf3508b4803087a7f259306781 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Fri, 1 May 2026 10:46:31 +0000 Subject: [PATCH] add native Bedrock Converse provider Made-with: Cursor --- docs/configuration.md | 178 +++++ nanobot/config/schema.py | 12 +- nanobot/providers/__init__.py | 3 + nanobot/providers/bedrock_provider.py | 730 ++++++++++++++++++++ nanobot/providers/factory.py | 16 + nanobot/providers/registry.py | 25 +- nanobot/session/manager.py | 2 +- pyproject.toml | 1 + tests/agent/test_session_manager_history.py | 6 + tests/providers/test_bedrock_provider.py | 254 +++++++ tests/providers/test_providers_init.py | 3 + 11 files changed, 1226 insertions(+), 4 deletions(-) create mode 100644 nanobot/providers/bedrock_provider.py create mode 100644 tests/providers/test_bedrock_provider.py diff --git a/docs/configuration.md b/docs/configuration.md index f295b50c..f20cec5f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -63,6 +63,7 @@ IMAP_PASSWORD=your-password-here | `byteplus` | LLM (VolcEngine international, pay-per-use) | [Coding Plan](https://www.byteplus.com/en/activity/codingplan?utm_campaign=nanobot&utm_content=nanobot&utm_medium=devrel&utm_source=OWO&utm_term=nanobot) ยท [byteplus.com](https://www.byteplus.com) | | `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | | `azure_openai` | LLM (Azure OpenAI) | [portal.azure.com](https://portal.azure.com) | +| `bedrock` | LLM (AWS Bedrock Converse, Claude/Nova/Llama/etc.) | [aws.amazon.com/bedrock](https://aws.amazon.com/bedrock/) | | `openai` | LLM + Voice transcription (Whisper) | [platform.openai.com](https://platform.openai.com) | | `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) | | `groq` | LLM + Voice transcription (Whisper, default) | [console.groq.com](https://console.groq.com) | @@ -85,6 +86,183 @@ IMAP_PASSWORD=your-password-here | `github_copilot` | LLM (GitHub Copilot, OAuth) | `nanobot provider login github-copilot` | | `qianfan` | LLM (Baidu Qianfan) | [cloud.baidu.com](https://cloud.baidu.com/doc/qianfan/s/Hmh4suq26) | +
+AWS Bedrock (Converse API) + +Bedrock uses the native `bedrock-runtime` Converse API, so it can call Bedrock model IDs such as Claude Opus 4.7, Claude Sonnet, Amazon Nova, Meta Llama, Mistral, Qwen, and other models that support Converse. It supports normal chat, streaming, tool calling, tool results, token usage, and Bedrock error metadata. + +This provider is for Bedrock's native Converse API, not Bedrock's OpenAI-compatible `/openai/v1` endpoint. For OpenAI-compatible Bedrock models, you can still use `custom` if you specifically want that API surface. + +**1. Configure credentials** + +Use the normal AWS credential chain (`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`, an AWS profile, or an IAM role). The IAM identity needs: + +```json +{ + "Effect": "Allow", + "Action": [ + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream" + ], + "Resource": "*" +} +``` + +You can also set `providers.bedrock.apiKey` to a Bedrock API key; nanobot exports it as `AWS_BEARER_TOKEN_BEDROCK` for the AWS SDK. + +Credential options: + +- **AWS CLI/default profile**: leave `apiKey` and `profile` empty, then run `aws configure` or provide `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`. +- **Named AWS profile**: set `profile` to a profile from `~/.aws/config` or `~/.aws/credentials`. +- **IAM role**: on EC2/ECS/Lambda, leave `apiKey` and `profile` empty and attach a role with Bedrock permissions. +- **Bedrock API key**: set `apiKey` or `AWS_BEARER_TOKEN_BEDROCK`; `profile` can stay `null`. + +**2. Minimal config** + +For a non-Anthropic model such as Amazon Nova: + +```json +{ + "providers": { + "bedrock": { + "region": "us-east-1" + } + }, + "agents": { + "defaults": { + "provider": "bedrock", + "model": "bedrock/amazon.nova-lite-v1:0", + "reasoningEffort": null + } + } +} +``` + +With a Bedrock API key: + +```json +{ + "providers": { + "bedrock": { + "region": "us-east-1", + "apiKey": "${AWS_BEARER_TOKEN_BEDROCK}" + } + }, + "agents": { + "defaults": { + "provider": "bedrock", + "model": "bedrock/amazon.nova-lite-v1:0", + "reasoningEffort": null + } + } +} +``` + +With a named AWS profile: + +```json +{ + "providers": { + "bedrock": { + "region": "us-east-1", + "profile": "my-bedrock-profile" + } + }, + "agents": { + "defaults": { + "provider": "bedrock", + "model": "bedrock/amazon.nova-lite-v1:0" + } + } +} +``` + +**3. Claude Opus 4.7 example** + +```json +{ + "providers": { + "bedrock": { + "region": "us-east-1" + } + }, + "agents": { + "defaults": { + "provider": "bedrock", + "model": "bedrock/global.anthropic.claude-opus-4-7", + "reasoningEffort": "medium", + "maxTokens": 8192 + } + } +} +``` + +For regional routing, use one of Bedrock's inference IDs, for example `bedrock/us.anthropic.claude-opus-4-7`, `bedrock/eu.anthropic.claude-opus-4-7`, or `bedrock/jp.anthropic.claude-opus-4-7`. + +Claude Opus 4.7 does not accept `temperature`, `top_p`, or `top_k`; nanobot omits `temperature` automatically for this model. If `reasoningEffort` is set to `low`, `medium`, `high`, `max`, or `adaptive`, nanobot sends Bedrock's adaptive thinking parameter. + +Anthropic models on Bedrock can also require Anthropic use-case registration and are subject to Anthropic-supported country/region restrictions. If Claude fails with a `ValidationException` about unsupported countries or regions, try a non-Anthropic Bedrock model such as Amazon Nova to verify the provider setup. + +**4. Model IDs** + +Use Bedrock model IDs or inference profile IDs with a `bedrock/` prefix in nanobot config. nanobot removes the prefix before calling AWS. + +Examples: + +- `bedrock/amazon.nova-micro-v1:0` +- `bedrock/amazon.nova-lite-v1:0` +- `bedrock/global.anthropic.claude-opus-4-7` +- `bedrock/us.anthropic.claude-opus-4-7` +- `bedrock/openai.gpt-oss-20b-1:0` +- `bedrock/meta.llama...` +- `bedrock/mistral...` + +Check the Bedrock console for the exact model ID and region availability. Some models require cross-region inference profile IDs such as `us.*`, `eu.*`, or `global.*`. + +**5. Advanced model fields** + +Model-specific fields can be supplied with `extraBody`; nanobot merges it into Converse `additionalModelRequestFields`: + +```json +{ + "providers": { + "bedrock": { + "region": "us-east-1", + "extraBody": { + "thinking": { + "type": "adaptive", + "effort": "medium", + "display": "summarized" + } + } + } + } +} +``` + +Use `apiBase` only for a custom Bedrock Runtime endpoint URL, such as a VPC endpoint or proxy. It is not needed for normal AWS regions. + +Current scope: nanobot passes `messages`, `system`, `inferenceConfig`, `toolConfig`, and `additionalModelRequestFields`. Bedrock Prompt Management, Guardrails, `serviceTier`, and other top-level Converse options are not first-class config fields yet. + +**6. Quick checks** + +```bash +# For AWS credential-chain usage: +aws sts get-caller-identity + +# For API-key usage: +export AWS_BEARER_TOKEN_BEDROCK="your-bedrock-api-key" +export AWS_REGION="us-east-1" +``` + +Then run: + +```bash +nanobot agent -m "Reply with one short sentence." +``` + +
+
OpenAI Codex (OAuth) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index a6c9d10c..210767bf 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -119,11 +119,19 @@ class ProviderConfig(Base): extra_body: dict[str, Any] | None = None # Extra fields merged into every request body +class BedrockProviderConfig(ProviderConfig): + """AWS Bedrock Runtime provider configuration.""" + + region: str | None = None # AWS region, falls back to AWS_REGION/AWS_DEFAULT_REGION/profile + profile: str | None = None # Optional AWS shared config profile + + class ProvidersConfig(Base): """Configuration for LLM providers.""" custom: ProviderConfig = Field(default_factory=ProviderConfig) # Any OpenAI-compatible endpoint azure_openai: ProviderConfig = Field(default_factory=ProviderConfig) # Azure OpenAI (model = deployment name) + bedrock: BedrockProviderConfig = Field(default_factory=BedrockProviderConfig) # AWS Bedrock Converse anthropic: ProviderConfig = Field(default_factory=ProviderConfig) openai: ProviderConfig = Field(default_factory=ProviderConfig) openrouter: ProviderConfig = Field(default_factory=ProviderConfig) @@ -287,14 +295,14 @@ class Config(BaseSettings): for spec in PROVIDERS: p = getattr(self.providers, spec.name, None) if p and model_prefix and normalized_prefix == spec.name: - if spec.is_oauth or spec.is_local or p.api_key: + if spec.is_oauth or spec.is_local or spec.is_direct or p.api_key: return p, spec.name # Match by keyword (order follows PROVIDERS registry) for spec in PROVIDERS: p = getattr(self.providers, spec.name, None) if p and any(_kw_matches(kw) for kw in spec.keywords): - if spec.is_oauth or spec.is_local or p.api_key: + if spec.is_oauth or spec.is_local or spec.is_direct or p.api_key: return p, spec.name # Fallback: configured local providers can route models without diff --git a/nanobot/providers/__init__.py b/nanobot/providers/__init__.py index ce237870..6150788a 100644 --- a/nanobot/providers/__init__.py +++ b/nanobot/providers/__init__.py @@ -15,6 +15,7 @@ __all__ = [ "OpenAICodexProvider", "GitHubCopilotProvider", "AzureOpenAIProvider", + "BedrockProvider", ] _LAZY_IMPORTS = { @@ -23,11 +24,13 @@ _LAZY_IMPORTS = { "OpenAICodexProvider": ".openai_codex_provider", "GitHubCopilotProvider": ".github_copilot_provider", "AzureOpenAIProvider": ".azure_openai_provider", + "BedrockProvider": ".bedrock_provider", } if TYPE_CHECKING: from nanobot.providers.anthropic_provider import AnthropicProvider from nanobot.providers.azure_openai_provider import AzureOpenAIProvider + from nanobot.providers.bedrock_provider import BedrockProvider from nanobot.providers.github_copilot_provider import GitHubCopilotProvider from nanobot.providers.openai_compat_provider import OpenAICompatProvider from nanobot.providers.openai_codex_provider import OpenAICodexProvider diff --git a/nanobot/providers/bedrock_provider.py b/nanobot/providers/bedrock_provider.py new file mode 100644 index 00000000..47963791 --- /dev/null +++ b/nanobot/providers/bedrock_provider.py @@ -0,0 +1,730 @@ +"""AWS Bedrock Converse provider.""" + +from __future__ import annotations + +import asyncio +import base64 +import json +import os +import re +from collections.abc import Awaitable, Callable, Iterator +from typing import Any + +import json_repair + +from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest + +_IMAGE_DATA_URL = re.compile(r"^data:image/([a-zA-Z0-9.+-]+);base64,(.*)$", re.DOTALL) +_TEXT_BLOCK_TYPES = {"text", "input_text", "output_text"} +_TEMPERATURE_UNSUPPORTED_MODEL_TOKENS = ("claude-opus-4-7",) +_ADAPTIVE_THINKING_ONLY_MODEL_TOKENS = ("claude-opus-4-7",) + + +def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + merged = dict(base) + for key, value in override.items(): + if key in merged and isinstance(merged[key], dict) and isinstance(value, dict): + merged[key] = _deep_merge(merged[key], value) + else: + merged[key] = value + return merged + + +def _next_or_none(iterator: Iterator[dict[str, Any]]) -> dict[str, Any] | None: + try: + return next(iterator) + except StopIteration: + return None + + +class BedrockProvider(LLMProvider): + """LLM provider using AWS Bedrock Runtime's Converse APIs.""" + + def __init__( + self, + api_key: str | None = None, + api_base: str | None = None, + default_model: str = "bedrock/global.anthropic.claude-opus-4-7", + *, + region: str | None = None, + profile: str | None = None, + extra_body: dict[str, Any] | None = None, + client: Any | None = None, + ): + super().__init__(api_key, api_base) + self.default_model = default_model + self.region = region or os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION") + self.profile = profile + self._extra_body = extra_body or {} + self._client = client if client is not None else self._make_client() + + def _make_client(self) -> Any: + if self.api_key: + os.environ["AWS_BEARER_TOKEN_BEDROCK"] = self.api_key + try: + import boto3 + except ImportError as exc: # pragma: no cover - exercised only without boto3 installed + raise RuntimeError( + "AWS Bedrock provider requires boto3. Install it with `pip install boto3`." + ) from exc + + session_kwargs: dict[str, Any] = {} + if self.profile: + session_kwargs["profile_name"] = self.profile + session = boto3.Session(**session_kwargs) + + client_kwargs: dict[str, Any] = {} + if self.region: + client_kwargs["region_name"] = self.region + if self.api_base: + client_kwargs["endpoint_url"] = self.api_base + return session.client("bedrock-runtime", **client_kwargs) + + @staticmethod + def _strip_prefix(model: str) -> str: + if model.startswith("bedrock/"): + return model[len("bedrock/"):] + return model + + @staticmethod + def _matches_model_token(model: str, tokens: tuple[str, ...]) -> bool: + model_lower = model.lower() + return any(token in model_lower for token in tokens) + + @classmethod + def _supports_temperature(cls, model: str) -> bool: + return not cls._matches_model_token(model, _TEMPERATURE_UNSUPPORTED_MODEL_TOKENS) + + @classmethod + def _uses_adaptive_thinking_only(cls, model: str) -> bool: + return cls._matches_model_token(model, _ADAPTIVE_THINKING_ONLY_MODEL_TOKENS) + + @staticmethod + def _image_url_block(block: dict[str, Any]) -> dict[str, Any] | None: + url = (block.get("image_url") or {}).get("url", "") + if not isinstance(url, str) or not url: + return None + match = _IMAGE_DATA_URL.match(url) + if not match: + return {"text": f"(image URL: {url})"} + fmt = match.group(1).lower() + if fmt == "jpg": + fmt = "jpeg" + try: + data = base64.b64decode(match.group(2), validate=False) + except Exception: + return {"text": "(invalid image data)"} + return {"image": {"format": fmt, "source": {"bytes": data}}} + + @classmethod + def _content_blocks(cls, content: Any, *, for_tool_result: bool = False) -> list[dict[str, Any]]: + if isinstance(content, str) or content is None: + return [{"text": content or "(empty)"}] + if not isinstance(content, list): + if for_tool_result and isinstance(content, dict): + return [{"json": content}] + return [{"text": str(content)}] + + blocks: list[dict[str, Any]] = [] + for item in content: + if not isinstance(item, dict): + blocks.append({"text": str(item)}) + continue + + item_type = item.get("type") + if item_type in _TEXT_BLOCK_TYPES or "text" in item: + text = item.get("text") + if text: + blocks.append({"text": str(text)}) + continue + if item_type == "image_url": + converted = cls._image_url_block(item) + if converted: + blocks.append(converted) + continue + + # Preserve already-Bedrock-shaped content where possible. + for key in ("text", "image", "document", "video", "json", "searchResult"): + if key in item: + blocks.append({key: item[key]}) + break + else: + blocks.append({"json": item} if for_tool_result else {"text": json.dumps(item)}) + + return blocks or [{"text": "(empty)"}] + + @classmethod + def _system_blocks(cls, content: Any) -> list[dict[str, Any]]: + return [ + block for block in cls._content_blocks(content) + if "text" in block or "cachePoint" in block or "guardContent" in block + ] + + @classmethod + def _tool_result_block(cls, msg: dict[str, Any]) -> dict[str, Any]: + return { + "toolResult": { + "toolUseId": str(msg.get("tool_call_id") or ""), + "content": cls._content_blocks(msg.get("content"), for_tool_result=True), + "status": "success", + } + } + + @staticmethod + def _tool_use_block(tool_call: dict[str, Any]) -> dict[str, Any] | None: + function = tool_call.get("function") + if not isinstance(function, dict): + return None + args = function.get("arguments", {}) + if isinstance(args, str): + try: + args = json_repair.loads(args) if args.strip() else {} + except Exception: + args = {} + if not isinstance(args, dict): + args = {} + return { + "toolUse": { + "toolUseId": str(tool_call.get("id") or ""), + "name": str(function.get("name") or ""), + "input": args, + } + } + + @staticmethod + def _reasoning_block(block: dict[str, Any]) -> dict[str, Any] | None: + if block.get("type") not in {"thinking", "reasoning", "redacted_thinking"}: + return None + text = block.get("thinking") or block.get("text") + signature = block.get("signature") + if text and signature: + return { + "reasoningContent": { + "reasoningText": {"text": str(text), "signature": str(signature)} + } + } + redacted = block.get("redactedContent") + if redacted is None and isinstance(block.get("redactedContentBase64"), str): + try: + redacted = base64.b64decode(block["redactedContentBase64"]) + except Exception: + redacted = None + if redacted is not None: + return {"reasoningContent": {"redactedContent": redacted}} + return None + + @classmethod + def _assistant_blocks(cls, msg: dict[str, Any]) -> list[dict[str, Any]]: + blocks: list[dict[str, Any]] = [] + + for thinking in msg.get("thinking_blocks") or []: + if isinstance(thinking, dict): + reasoning = cls._reasoning_block(thinking) + if reasoning: + blocks.append(reasoning) + + content = msg.get("content") + if isinstance(content, str) and content: + blocks.append({"text": content}) + elif isinstance(content, list): + blocks.extend(block for block in cls._content_blocks(content) if "text" in block) + + for tool_call in msg.get("tool_calls") or []: + if isinstance(tool_call, dict): + block = cls._tool_use_block(tool_call) + if block: + blocks.append(block) + + return blocks or [{"text": ""}] + + @staticmethod + def _has_tool_use(msg: dict[str, Any]) -> bool: + content = msg.get("content") + return isinstance(content, list) and any( + isinstance(block, dict) and "toolUse" in block for block in content + ) + + @staticmethod + def _merge_consecutive(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + merged: list[dict[str, Any]] = [] + for msg in messages: + if merged and merged[-1].get("role") == msg.get("role"): + prev = merged[-1].setdefault("content", []) + cur = msg.get("content") or [] + if not isinstance(prev, list): + prev = [{"text": str(prev)}] + merged[-1]["content"] = prev + if isinstance(cur, list): + prev.extend(cur) + else: + prev.append({"text": str(cur)}) + else: + merged.append(msg) + + last_popped: dict[str, Any] | None = None + while merged and merged[-1].get("role") == "assistant": + last_popped = merged.pop() + if not merged and last_popped is not None and not BedrockProvider._has_tool_use(last_popped): + merged.append({"role": "user", "content": last_popped.get("content") or [{"text": "(empty)"}]}) + if merged and merged[0].get("role") == "assistant" and not BedrockProvider._has_tool_use(merged[0]): + merged.insert(0, {"role": "user", "content": [{"text": "(conversation continued)"}]}) + return merged + + def _convert_messages( + self, + messages: list[dict[str, Any]], + ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + system: list[dict[str, Any]] = [] + converted: list[dict[str, Any]] = [] + + for msg in messages: + role = msg.get("role") + content = msg.get("content") + if role == "system": + system.extend(self._system_blocks(content)) + continue + if role == "tool": + block = self._tool_result_block(msg) + if converted and converted[-1].get("role") == "user": + converted[-1].setdefault("content", []).append(block) + else: + converted.append({"role": "user", "content": [block]}) + continue + if role == "assistant": + converted.append({"role": "assistant", "content": self._assistant_blocks(msg)}) + continue + if role == "user": + converted.append({"role": "user", "content": self._content_blocks(content)}) + + return system, self._merge_consecutive(converted) + + @staticmethod + def _convert_tools(tools: list[dict[str, Any]] | None) -> list[dict[str, Any]] | None: + if not tools: + return None + result: list[dict[str, Any]] = [] + for tool in tools: + func = tool.get("function") if isinstance(tool.get("function"), dict) else tool + if not isinstance(func, dict): + continue + name = str(func.get("name") or "") + if not name: + continue + spec: dict[str, Any] = { + "name": name, + "inputSchema": { + "json": func.get("parameters") or {"type": "object", "properties": {}} + }, + } + description = func.get("description") + if description: + spec["description"] = str(description) + strict = func.get("strict", tool.get("strict")) + if isinstance(strict, bool): + spec["strict"] = strict + result.append({"toolSpec": spec}) + return result or None + + @staticmethod + def _convert_tool_choice( + tool_choice: str | dict[str, Any] | None, + ) -> dict[str, Any] | None: + if tool_choice is None or tool_choice == "auto": + return {"auto": {}} + if tool_choice == "required": + return {"any": {}} + if tool_choice == "none": + return None + if isinstance(tool_choice, dict): + name = tool_choice.get("function", {}).get("name") + if name: + return {"tool": {"name": str(name)}} + return {"auto": {}} + + @staticmethod + def _adaptive_thinking(reasoning_effort: str | None) -> dict[str, Any] | None: + if not reasoning_effort: + return None + effort = reasoning_effort.lower() + if effort == "none": + return None + thinking: dict[str, Any] = {"type": "adaptive"} + if effort != "adaptive": + thinking["effort"] = effort + return thinking + + def _build_kwargs( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None, + model: str | None, + max_tokens: int, + temperature: float, + reasoning_effort: str | None, + tool_choice: str | dict[str, Any] | None, + ) -> dict[str, Any]: + model_id = self._strip_prefix(model or self.default_model) + system, bedrock_messages = self._convert_messages(self._sanitize_empty_content(messages)) + if not bedrock_messages: + bedrock_messages = [{"role": "user", "content": [{"text": "(empty)"}]}] + + kwargs: dict[str, Any] = { + "modelId": model_id, + "messages": bedrock_messages, + "inferenceConfig": {"maxTokens": max(1, max_tokens)}, + } + if system: + kwargs["system"] = system + if self._supports_temperature(model_id): + kwargs["inferenceConfig"]["temperature"] = temperature + + additional: dict[str, Any] = {} + if self._uses_adaptive_thinking_only(model_id): + thinking = self._adaptive_thinking(reasoning_effort) + if thinking: + additional["thinking"] = thinking + if self._extra_body: + additional = _deep_merge(additional, self._extra_body) + if additional: + kwargs["additionalModelRequestFields"] = additional + + bedrock_tools = self._convert_tools(tools) + if bedrock_tools: + tool_config: dict[str, Any] = {"tools": bedrock_tools} + choice = self._convert_tool_choice(tool_choice) + if choice: + tool_config["toolChoice"] = choice + kwargs["toolConfig"] = tool_config + + return kwargs + + @staticmethod + def _finish_reason(stop_reason: str | None) -> str: + return { + "end_turn": "stop", + "tool_use": "tool_calls", + "max_tokens": "length", + }.get(stop_reason or "", stop_reason or "stop") + + @staticmethod + def _usage(usage: dict[str, Any] | None) -> dict[str, int]: + if not usage: + return {} + prompt = int(usage.get("inputTokens") or 0) + completion = int(usage.get("outputTokens") or 0) + total = int(usage.get("totalTokens") or prompt + completion) + result = { + "prompt_tokens": prompt, + "completion_tokens": completion, + "total_tokens": total, + } + cache_read = int(usage.get("cacheReadInputTokens") or 0) + cache_write = int(usage.get("cacheWriteInputTokens") or 0) + if cache_read: + result["cached_tokens"] = cache_read + result["cache_read_input_tokens"] = cache_read + if cache_write: + result["cache_creation_input_tokens"] = cache_write + return result + + @staticmethod + def _parse_reasoning(block: dict[str, Any]) -> tuple[str | None, dict[str, Any] | None]: + reasoning = block.get("reasoningContent") + if not isinstance(reasoning, dict): + return None, None + text_obj = reasoning.get("reasoningText") + if isinstance(text_obj, dict): + text = text_obj.get("text") + if isinstance(text, str): + return text, { + "type": "thinking", + "thinking": text, + "signature": text_obj.get("signature", ""), + } + redacted = reasoning.get("redactedContent") + if redacted is not None: + if isinstance(redacted, (bytes, bytearray)): + encoded = base64.b64encode(bytes(redacted)).decode("ascii") + return None, {"type": "redacted_thinking", "redactedContentBase64": encoded} + return None, {"type": "redacted_thinking", "redactedContent": redacted} + return None, None + + @classmethod + def _parse_response(cls, response: dict[str, Any]) -> LLMResponse: + content_parts: list[str] = [] + reasoning_parts: list[str] = [] + tool_calls: list[ToolCallRequest] = [] + thinking_blocks: list[dict[str, Any]] = [] + message = (response.get("output") or {}).get("message") or {} + + for block in message.get("content") or []: + if not isinstance(block, dict): + continue + if isinstance(block.get("text"), str): + content_parts.append(block["text"]) + tool_use = block.get("toolUse") + if isinstance(tool_use, dict): + arguments = tool_use.get("input") if isinstance(tool_use.get("input"), dict) else {} + tool_calls.append(ToolCallRequest( + id=str(tool_use.get("toolUseId") or ""), + name=str(tool_use.get("name") or ""), + arguments=arguments, + )) + reasoning_text, thinking = cls._parse_reasoning(block) + if reasoning_text: + reasoning_parts.append(reasoning_text) + if thinking: + thinking_blocks.append(thinking) + + return LLMResponse( + content="".join(content_parts) or None, + tool_calls=tool_calls, + finish_reason=cls._finish_reason(response.get("stopReason")), + usage=cls._usage(response.get("usage")), + reasoning_content="".join(reasoning_parts) or None, + thinking_blocks=thinking_blocks or None, + ) + + @classmethod + def _parse_stream_event( + cls, + event: dict[str, Any], + *, + content_parts: list[str], + reasoning_parts: list[str], + thinking_blocks: list[dict[str, Any]], + tool_buffers: dict[int, dict[str, Any]], + state: dict[str, Any], + ) -> str | None: + if "contentBlockStart" in event: + data = event["contentBlockStart"] + idx = int(data.get("contentBlockIndex") or 0) + start = data.get("start") or {} + tool_use = start.get("toolUse") + if isinstance(tool_use, dict): + tool_buffers[idx] = { + "id": str(tool_use.get("toolUseId") or ""), + "name": str(tool_use.get("name") or ""), + "input": "", + } + return None + + if "contentBlockDelta" in event: + data = event["contentBlockDelta"] + idx = int(data.get("contentBlockIndex") or 0) + delta = data.get("delta") or {} + text = delta.get("text") + if isinstance(text, str): + content_parts.append(text) + return text + tool_delta = delta.get("toolUse") + if isinstance(tool_delta, dict): + buf = tool_buffers.setdefault(idx, {"id": "", "name": "", "input": ""}) + if isinstance(tool_delta.get("input"), str): + buf["input"] += tool_delta["input"] + reasoning = delta.get("reasoningContent") + if isinstance(reasoning, dict): + buf = state.setdefault("reasoning_buffers", {}).setdefault( + idx, {"text": "", "signature": "", "redactedContent": None} + ) + if isinstance(reasoning.get("text"), str): + buf["text"] += reasoning["text"] + reasoning_parts.append(reasoning["text"]) + if isinstance(reasoning.get("signature"), str): + buf["signature"] = reasoning["signature"] + if reasoning.get("redactedContent") is not None: + buf["redactedContent"] = reasoning["redactedContent"] + return None + + if "contentBlockStop" in event: + idx = int((event["contentBlockStop"] or {}).get("contentBlockIndex") or 0) + reasoning_buf = state.setdefault("reasoning_buffers", {}).pop(idx, None) + if reasoning_buf: + if reasoning_buf.get("text"): + thinking_blocks.append({ + "type": "thinking", + "thinking": reasoning_buf["text"], + "signature": reasoning_buf.get("signature", ""), + }) + elif reasoning_buf.get("redactedContent") is not None: + redacted = reasoning_buf["redactedContent"] + if isinstance(redacted, (bytes, bytearray)): + redacted_block = { + "type": "redacted_thinking", + "redactedContentBase64": base64.b64encode(bytes(redacted)).decode("ascii"), + } + else: + redacted_block = { + "type": "redacted_thinking", + "redactedContent": redacted, + } + thinking_blocks.append({ + **redacted_block, + }) + return None + + if "messageStop" in event: + state["stop_reason"] = (event["messageStop"] or {}).get("stopReason") + return None + + if "metadata" in event: + metadata = event["metadata"] or {} + if isinstance(metadata.get("usage"), dict): + state["usage"] = metadata["usage"] + return None + + return None + + @classmethod + def _stream_result( + cls, + *, + content_parts: list[str], + reasoning_parts: list[str], + thinking_blocks: list[dict[str, Any]], + tool_buffers: dict[int, dict[str, Any]], + state: dict[str, Any], + ) -> LLMResponse: + tool_calls: list[ToolCallRequest] = [] + for buf in tool_buffers.values(): + args: Any = {} + if buf.get("input"): + try: + args = json_repair.loads(buf["input"]) + except Exception: + args = {} + tool_calls.append(ToolCallRequest( + id=buf.get("id") or "", + name=buf.get("name") or "", + arguments=args if isinstance(args, dict) else {}, + )) + return LLMResponse( + content="".join(content_parts) or None, + tool_calls=tool_calls, + finish_reason=cls._finish_reason(state.get("stop_reason")), + usage=cls._usage(state.get("usage")), + reasoning_content="".join(reasoning_parts) or None, + thinking_blocks=thinking_blocks or None, + ) + + @classmethod + def _handle_error(cls, e: Exception) -> LLMResponse: + response = getattr(e, "response", None) + metadata = response.get("ResponseMetadata", {}) if isinstance(response, dict) else {} + headers = metadata.get("HTTPHeaders") if isinstance(metadata, dict) else None + error_obj = response.get("Error", {}) if isinstance(response, dict) else {} + message = error_obj.get("Message") if isinstance(error_obj, dict) else None + code = error_obj.get("Code") if isinstance(error_obj, dict) else None + status_code = metadata.get("HTTPStatusCode") if isinstance(metadata, dict) else None + body = message or str(e) + retry_after = cls._extract_retry_after_from_headers(headers) + if retry_after is None: + retry_after = cls._extract_retry_after(body) + + error_name = e.__class__.__name__.lower() + error_kind = None + if "timeout" in error_name: + error_kind = "timeout" + elif "connection" in error_name or "endpoint" in error_name: + error_kind = "connection" + + code_text = str(code or "").lower() + should_retry = None + if status_code is not None: + should_retry = int(status_code) == 429 or int(status_code) >= 500 + if any(token in code_text for token in ("throttl", "timeout", "unavailable", "modelnotready")): + should_retry = True + + return LLMResponse( + content=f"Error: {str(body).strip()[:500]}", + finish_reason="error", + retry_after=retry_after, + error_status_code=int(status_code) if status_code is not None else None, + error_kind=error_kind, + error_type=code_text or None, + error_code=code_text or None, + error_retry_after_s=retry_after, + error_should_retry=should_retry, + ) + + async def chat( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None = None, + model: str | None = None, + max_tokens: int = 4096, + temperature: float = 0.7, + reasoning_effort: str | None = None, + tool_choice: str | dict[str, Any] | None = None, + ) -> LLMResponse: + try: + kwargs = self._build_kwargs( + messages, tools, model, max_tokens, temperature, reasoning_effort, tool_choice + ) + response = await asyncio.to_thread(self._client.converse, **kwargs) + return self._parse_response(response) + except Exception as e: + return self._handle_error(e) + + async def chat_stream( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None = None, + model: str | None = None, + max_tokens: int = 4096, + temperature: float = 0.7, + reasoning_effort: str | None = None, + tool_choice: str | dict[str, Any] | None = None, + on_content_delta: Callable[[str], Awaitable[None]] | None = None, + ) -> LLMResponse: + idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90")) + content_parts: list[str] = [] + reasoning_parts: list[str] = [] + thinking_blocks: list[dict[str, Any]] = [] + tool_buffers: dict[int, dict[str, Any]] = {} + state: dict[str, Any] = {} + + try: + kwargs = self._build_kwargs( + messages, tools, model, max_tokens, temperature, reasoning_effort, tool_choice + ) + response = await asyncio.to_thread(self._client.converse_stream, **kwargs) + stream = iter(response.get("stream") or []) + while True: + event = await asyncio.wait_for( + asyncio.to_thread(_next_or_none, stream), + timeout=idle_timeout_s, + ) + if event is None: + break + delta = self._parse_stream_event( + event, + content_parts=content_parts, + reasoning_parts=reasoning_parts, + thinking_blocks=thinking_blocks, + tool_buffers=tool_buffers, + state=state, + ) + if delta and on_content_delta: + await on_content_delta(delta) + return self._stream_result( + content_parts=content_parts, + reasoning_parts=reasoning_parts, + thinking_blocks=thinking_blocks, + tool_buffers=tool_buffers, + state=state, + ) + except asyncio.TimeoutError: + return LLMResponse( + content=( + f"Error calling LLM: stream stalled for more than " + f"{idle_timeout_s} seconds" + ), + finish_reason="error", + error_kind="timeout", + ) + except Exception as e: + return self._handle_error(e) + + def get_default_model(self) -> str: + return self.default_model diff --git a/nanobot/providers/factory.py b/nanobot/providers/factory.py index 5f97b04a..d7139094 100644 --- a/nanobot/providers/factory.py +++ b/nanobot/providers/factory.py @@ -60,6 +60,17 @@ def make_provider(config: Config) -> LLMProvider: default_model=model, extra_headers=p.extra_headers if p else None, ) + elif backend == "bedrock": + from nanobot.providers.bedrock_provider import BedrockProvider + + provider = BedrockProvider( + api_key=p.api_key if p else None, + api_base=p.api_base if p else None, + default_model=model, + region=getattr(p, "region", None) if p else None, + profile=getattr(p, "profile", None) if p else None, + extra_body=p.extra_body if p else None, + ) else: from nanobot.providers.openai_compat_provider import OpenAICompatProvider @@ -85,12 +96,17 @@ def provider_signature(config: Config) -> tuple[object, ...]: """Return the config fields that affect the primary LLM provider.""" model = config.agents.defaults.model defaults = config.agents.defaults + p = config.get_provider(model) return ( model, defaults.provider, config.get_provider_name(model), config.get_api_key(model), config.get_api_base(model), + p.extra_headers if p else None, + p.extra_body if p else None, + getattr(p, "region", None) if p else None, + getattr(p, "profile", None) if p else None, defaults.max_tokens, defaults.temperature, defaults.reasoning_effort, diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 6f947bab..a6404168 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -34,7 +34,7 @@ class ProviderSpec: display_name: str = "" # shown in `nanobot status` # which provider implementation to use - # "openai_compat" | "anthropic" | "azure_openai" | "openai_codex" | "github_copilot" + # "openai_compat" | "anthropic" | "azure_openai" | "openai_codex" | "github_copilot" | "bedrock" backend: str = "openai_compat" # extra env vars, e.g. (("ZHIPUAI_API_KEY", "{api_key}"),) @@ -105,6 +105,29 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( backend="azure_openai", is_direct=True, ), + # === AWS Bedrock (native Converse API via bedrock-runtime) ============= + ProviderSpec( + name="bedrock", + keywords=( + "bedrock", + "anthropic.claude", + "amazon.nova", + "meta.", + "mistral.", + "cohere.", + "qwen.", + "deepseek.", + "openai.gpt-oss", + "ai21.", + "moonshot.", + "writer.", + "zai.", + ), + env_key="AWS_BEARER_TOKEN_BEDROCK", + display_name="AWS Bedrock", + backend="bedrock", + is_direct=True, + ), # === Gateways (detected by api_key / api_base, not model name) ========= # Gateways can route any model, so they win in fallback. # OpenRouter: global gateway, keys start with "sk-or-" diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index de97135d..fb1d6cf6 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -118,7 +118,7 @@ class Session: if include_timestamps: content = self._annotate_message_time(message, content) entry: dict[str, Any] = {"role": message["role"], "content": content} - for key in ("tool_calls", "tool_call_id", "name", "reasoning_content"): + for key in ("tool_calls", "tool_call_id", "name", "reasoning_content", "thinking_blocks"): if key in message: entry[key] = message[key] out.append(entry) diff --git a/pyproject.toml b/pyproject.toml index 36185f39..ff3b2a34 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,6 +61,7 @@ dependencies = [ "openpyxl>=3.1.0,<4.0.0", "python-pptx>=1.0.0,<2.0.0", "filelock>=3.25.2", + "boto3>=1.43.0", ] [project.optional-dependencies] diff --git a/tests/agent/test_session_manager_history.py b/tests/agent/test_session_manager_history.py index eff260b7..b80c774a 100644 --- a/tests/agent/test_session_manager_history.py +++ b/tests/agent/test_session_manager_history.py @@ -180,6 +180,7 @@ def test_get_history_preserves_reasoning_content(): "role": "assistant", "content": "done", "reasoning_content": "hidden chain of thought", + "thinking_blocks": [{"type": "thinking", "thinking": "hidden chain of thought", "signature": "sig"}], }) history = session.get_history(max_messages=500) @@ -190,6 +191,11 @@ def test_get_history_preserves_reasoning_content(): "role": "assistant", "content": "done", "reasoning_content": "hidden chain of thought", + "thinking_blocks": [{ + "type": "thinking", + "thinking": "hidden chain of thought", + "signature": "sig", + }], }, ] diff --git a/tests/providers/test_bedrock_provider.py b/tests/providers/test_bedrock_provider.py new file mode 100644 index 00000000..e86b8426 --- /dev/null +++ b/tests/providers/test_bedrock_provider.py @@ -0,0 +1,254 @@ +"""Tests for the native AWS Bedrock Converse provider.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from nanobot.config.schema import Config, ProvidersConfig +from nanobot.providers.bedrock_provider import BedrockProvider +from nanobot.providers.registry import find_by_name + + +class FakeClient: + def __init__( + self, + *, + response: dict[str, Any] | None = None, + stream_events: list[dict[str, Any]] | None = None, + error: Exception | None = None, + ) -> None: + self.response = response + self.stream_events = stream_events or [] + self.error = error + self.calls: list[dict[str, Any]] = [] + self.stream_calls: list[dict[str, Any]] = [] + + def converse(self, **kwargs): + self.calls.append(kwargs) + if self.error: + raise self.error + return self.response or {} + + def converse_stream(self, **kwargs): + self.stream_calls.append(kwargs) + if self.error: + raise self.error + return {"stream": iter(self.stream_events)} + + +class FakeBedrockError(Exception): + def __init__(self) -> None: + super().__init__("too many requests") + self.response = { + "ResponseMetadata": { + "HTTPStatusCode": 429, + "HTTPHeaders": {"retry-after": "3"}, + }, + "Error": { + "Code": "ThrottlingException", + "Message": "Rate exceeded", + }, + } + + +def test_bedrock_provider_is_registered_and_matches_without_api_key() -> None: + spec = find_by_name("bedrock") + assert spec is not None + assert spec.backend == "bedrock" + assert spec.is_direct is True + assert hasattr(ProvidersConfig(), "bedrock") + + cfg = Config.model_validate({ + "agents": {"defaults": {"model": "bedrock/global.anthropic.claude-opus-4-7"}}, + "providers": {"bedrock": {"region": "us-east-1"}}, + }) + + assert cfg.get_provider_name() == "bedrock" + assert cfg.get_provider().region == "us-east-1" + + +def test_opus_47_uses_adaptive_thinking_and_omits_temperature() -> None: + provider = BedrockProvider(region="us-east-1", client=FakeClient()) + + kwargs = provider._build_kwargs( + messages=[{"role": "user", "content": "hi"}], + tools=None, + model="bedrock/global.anthropic.claude-opus-4-7", + max_tokens=2048, + temperature=0.1, + reasoning_effort="medium", + tool_choice=None, + ) + + assert kwargs["modelId"] == "global.anthropic.claude-opus-4-7" + assert kwargs["inferenceConfig"] == {"maxTokens": 2048} + assert kwargs["additionalModelRequestFields"]["thinking"] == { + "type": "adaptive", + "effort": "medium", + } + + +def test_generic_bedrock_model_keeps_temperature_and_skips_anthropic_thinking() -> None: + provider = BedrockProvider(region="us-east-1", client=FakeClient()) + + kwargs = provider._build_kwargs( + messages=[{"role": "user", "content": "hi"}], + tools=None, + model="bedrock/amazon.nova-lite-v1:0", + max_tokens=1024, + temperature=0.3, + reasoning_effort="medium", + tool_choice=None, + ) + + assert kwargs["modelId"] == "amazon.nova-lite-v1:0" + assert kwargs["inferenceConfig"] == {"maxTokens": 1024, "temperature": 0.3} + assert "additionalModelRequestFields" not in kwargs + + +def test_build_kwargs_converts_messages_tools_and_tool_results() -> None: + provider = BedrockProvider(region="us-east-1", client=FakeClient()) + tools = [{ + "type": "function", + "function": { + "name": "read_file", + "description": "Read a file", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}}}, + }, + }] + messages = [ + {"role": "system", "content": "You are helpful."}, + {"role": "user", "content": "read x"}, + { + "role": "assistant", + "content": "", + "tool_calls": [{ + "id": "toolu_1", + "type": "function", + "function": {"name": "read_file", "arguments": '{"path": "x"}'}, + }], + }, + {"role": "tool", "tool_call_id": "toolu_1", "name": "read_file", "content": "ok"}, + {"role": "user", "content": "continue"}, + ] + + kwargs = provider._build_kwargs( + messages=messages, + tools=tools, + model="bedrock/anthropic.claude-opus-4-7", + max_tokens=1024, + temperature=0.7, + reasoning_effort=None, + tool_choice="required", + ) + + assert kwargs["system"] == [{"text": "You are helpful."}] + assert kwargs["messages"][1]["content"] == [{ + "toolUse": { + "toolUseId": "toolu_1", + "name": "read_file", + "input": {"path": "x"}, + } + }] + assert kwargs["messages"][2]["role"] == "user" + assert kwargs["messages"][2]["content"][0]["toolResult"]["toolUseId"] == "toolu_1" + assert kwargs["messages"][2]["content"][1] == {"text": "continue"} + tool_spec = kwargs["toolConfig"]["tools"][0]["toolSpec"] + assert tool_spec["name"] == "read_file" + assert kwargs["toolConfig"]["toolChoice"] == {"any": {}} + + +def test_parse_response_maps_text_tools_reasoning_usage_and_stop_reason() -> None: + response = { + "output": { + "message": { + "role": "assistant", + "content": [ + {"reasoningContent": {"reasoningText": {"text": "think", "signature": "sig"}}}, + {"text": "hello"}, + {"toolUse": {"toolUseId": "t1", "name": "search", "input": {"q": "x"}}}, + ], + } + }, + "stopReason": "tool_use", + "usage": { + "inputTokens": 10, + "outputTokens": 5, + "totalTokens": 15, + "cacheReadInputTokens": 2, + }, + } + + result = BedrockProvider._parse_response(response) + + assert result.content == "hello" + assert result.finish_reason == "tool_calls" + assert result.usage["prompt_tokens"] == 10 + assert result.usage["cached_tokens"] == 2 + assert result.reasoning_content == "think" + assert result.thinking_blocks == [{"type": "thinking", "thinking": "think", "signature": "sig"}] + assert result.tool_calls[0].id == "t1" + assert result.tool_calls[0].arguments == {"q": "x"} + + +@pytest.mark.asyncio +async def test_chat_stream_aggregates_text_tool_use_and_usage() -> None: + client = FakeClient(stream_events=[ + {"contentBlockDelta": {"contentBlockIndex": 0, "delta": {"text": "he"}}}, + {"contentBlockDelta": {"contentBlockIndex": 0, "delta": {"text": "llo"}}}, + { + "contentBlockStart": { + "contentBlockIndex": 1, + "start": {"toolUse": {"toolUseId": "t1", "name": "search"}}, + } + }, + { + "contentBlockDelta": { + "contentBlockIndex": 1, + "delta": {"toolUse": {"input": '{"q":'}}, + } + }, + { + "contentBlockDelta": { + "contentBlockIndex": 1, + "delta": {"toolUse": {"input": '"x"}'}}, + } + }, + {"contentBlockStop": {"contentBlockIndex": 1}}, + {"messageStop": {"stopReason": "tool_use"}}, + {"metadata": {"usage": {"inputTokens": 3, "outputTokens": 4, "totalTokens": 7}}}, + ]) + provider = BedrockProvider(region="us-east-1", client=client) + deltas: list[str] = [] + + result = await provider.chat_stream( + messages=[{"role": "user", "content": "hi"}], + model="bedrock/anthropic.claude-opus-4-7", + on_content_delta=lambda text: _append_delta(deltas, text), + ) + + assert deltas == ["he", "llo"] + assert result.content == "hello" + assert result.finish_reason == "tool_calls" + assert result.usage == {"prompt_tokens": 3, "completion_tokens": 4, "total_tokens": 7} + assert result.tool_calls[0].name == "search" + assert result.tool_calls[0].arguments == {"q": "x"} + + +async def _append_delta(deltas: list[str], text: str) -> None: + deltas.append(text) + + +@pytest.mark.asyncio +async def test_chat_error_maps_retry_metadata() -> None: + provider = BedrockProvider(region="us-east-1", client=FakeClient(error=FakeBedrockError())) + + result = await provider.chat(messages=[{"role": "user", "content": "hi"}]) + + assert result.finish_reason == "error" + assert result.error_status_code == 429 + assert result.error_should_retry is True + assert result.error_code == "throttlingexception" + assert result.retry_after == 3 diff --git a/tests/providers/test_providers_init.py b/tests/providers/test_providers_init.py index 620cf8da..707a3f68 100644 --- a/tests/providers/test_providers_init.py +++ b/tests/providers/test_providers_init.py @@ -13,6 +13,7 @@ def test_importing_providers_package_is_lazy(monkeypatch) -> None: monkeypatch.delitem(sys.modules, "nanobot.providers.openai_codex_provider", raising=False) monkeypatch.delitem(sys.modules, "nanobot.providers.github_copilot_provider", raising=False) monkeypatch.delitem(sys.modules, "nanobot.providers.azure_openai_provider", raising=False) + monkeypatch.delitem(sys.modules, "nanobot.providers.bedrock_provider", raising=False) providers = importlib.import_module("nanobot.providers") @@ -21,6 +22,7 @@ def test_importing_providers_package_is_lazy(monkeypatch) -> None: assert "nanobot.providers.openai_codex_provider" not in sys.modules assert "nanobot.providers.github_copilot_provider" not in sys.modules assert "nanobot.providers.azure_openai_provider" not in sys.modules + assert "nanobot.providers.bedrock_provider" not in sys.modules assert providers.__all__ == [ "LLMProvider", "LLMResponse", @@ -29,6 +31,7 @@ def test_importing_providers_package_is_lazy(monkeypatch) -> None: "OpenAICodexProvider", "GitHubCopilotProvider", "AzureOpenAIProvider", + "BedrockProvider", ]