diff --git a/py/packages/genkit-amazon-bedrock/LICENSE b/py/packages/genkit-amazon-bedrock/LICENSE new file mode 100644 index 0000000000..2205396735 --- /dev/null +++ b/py/packages/genkit-amazon-bedrock/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 Google LLC + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/py/packages/genkit-amazon-bedrock/README.md b/py/packages/genkit-amazon-bedrock/README.md new file mode 100644 index 0000000000..ad1c38a72f --- /dev/null +++ b/py/packages/genkit-amazon-bedrock/README.md @@ -0,0 +1,42 @@ +# Genkit Amazon Bedrock Plugin + +Amazon Bedrock plugin for Genkit Python. Provides access to Bedrock-hosted +models (Anthropic Claude, Amazon Nova, Meta Llama, Mistral, Cohere, and +others), Titan/Cohere/Nova embedders, image generation, and Cohere reranking. + +> Status: in progress. Non-streaming text generation (Converse) is available; +> remaining feature slices are being ported from the mature Go plugin +> ([genkit-ai/aws-bedrock-go-plugin](https://github.com/genkit-ai/aws-bedrock-go-plugin)). + +## Installation + +```bash +pip install genkit-amazon-bedrock +``` + +## Usage + +```python +from genkit import Genkit +from genkit_amazon_bedrock import Bedrock, ModelDefinition + +ai = Genkit( + plugins=[ + Bedrock( + region='us-east-1', + models=[ModelDefinition(name='anthropic.claude-sonnet-4-5-20250929-v1:0')], + ) + ], + model='bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0', +) +``` + +Credentials resolve through the standard AWS SDK chain (environment, +`~/.aws/credentials`, instance metadata). Pass a pre-configured +`boto3.session.Session` via `session=` for custom wiring. The region comes +from `region=` or the SDK chain (`AWS_REGION`, `AWS_DEFAULT_REGION`, +`~/.aws/config`); there is deliberately no default region. + +## License + +Apache 2.0 diff --git a/py/packages/genkit-amazon-bedrock/pyproject.toml b/py/packages/genkit-amazon-bedrock/pyproject.toml new file mode 100644 index 0000000000..79532c79eb --- /dev/null +++ b/py/packages/genkit-amazon-bedrock/pyproject.toml @@ -0,0 +1,72 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 + +[project] +authors = [{ name = "Google" }] +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Console", + "Environment :: Web Environment", + "Framework :: AsyncIO", + "Framework :: Pydantic", + "Framework :: Pydantic :: 2", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Topic :: Software Development :: Libraries", + "Typing :: Typed", + "License :: OSI Approved :: Apache Software License", +] +dependencies = ["genkit", "boto3>=1.37.24"] +description = "Genkit Amazon Bedrock Plugin" +keywords = [ + "genkit", + "ai", + "llm", + "machine-learning", + "artificial-intelligence", + "generative-ai", + "aws", + "bedrock", + "amazon", +] +license = "Apache-2.0" +name = "genkit-amazon-bedrock" +readme = "README.md" +requires-python = ">=3.10" +version = "0.8.1" + +[project.urls] +"Bug Tracker" = "https://github.com/genkit-ai/genkit/issues" +Changelog = "https://github.com/genkit-ai/genkit/blob/main/py/packages/genkit-amazon-bedrock/CHANGELOG.md" +"Documentation" = "https://firebase.google.com/docs/genkit" +"Homepage" = "https://github.com/genkit-ai/genkit" +"Repository" = "https://github.com/genkit-ai/genkit/tree/main/py" + +[build-system] +build-backend = "hatchling.build" +requires = ["hatchling"] + +[tool.hatch.build.targets.wheel] +only-include = ["src/genkit_amazon_bedrock"] +sources = ["src"] diff --git a/py/packages/genkit-amazon-bedrock/src/genkit_amazon_bedrock/__init__.py b/py/packages/genkit-amazon-bedrock/src/genkit_amazon_bedrock/__init__.py new file mode 100644 index 0000000000..ef79283da7 --- /dev/null +++ b/py/packages/genkit-amazon-bedrock/src/genkit_amazon_bedrock/__init__.py @@ -0,0 +1,29 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Amazon Bedrock plugin for Genkit.""" + +from genkit_amazon_bedrock.config import BedrockConfig, ModelDefinition +from genkit_amazon_bedrock.converters import cache_point_part +from genkit_amazon_bedrock.plugin import Bedrock, bedrock_name + +__all__ = [ + 'Bedrock', + 'BedrockConfig', + 'ModelDefinition', + 'bedrock_name', + 'cache_point_part', +] diff --git a/py/packages/genkit-amazon-bedrock/src/genkit_amazon_bedrock/config.py b/py/packages/genkit-amazon-bedrock/src/genkit_amazon_bedrock/config.py new file mode 100644 index 0000000000..33a00aa779 --- /dev/null +++ b/py/packages/genkit-amazon-bedrock/src/genkit_amazon_bedrock/config.py @@ -0,0 +1,71 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Configuration types for the Amazon Bedrock plugin.""" + +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + +from genkit import ModelConfig + +DEFAULT_MAX_RETRIES = 3 +# Socket read timeout, not a whole-call deadline: Bedrock generations can +# legitimately run for many minutes (Nova allows 60-minute inference). +DEFAULT_READ_TIMEOUT = 3600.0 +DEFAULT_CONNECT_TIMEOUT = 60.0 +# The botocore default of 10 pooled connections throttles LLM concurrency. +DEFAULT_MAX_POOL_CONNECTIONS = 50 + + +class BedrockConfig(ModelConfig): + """Per-call configuration for Bedrock models. + + Mirrors the Go plugin's ``Config`` surface. Unknown keys are tolerated for + forward compatibility but only the declared fields (and + ``additional_model_request_fields``) reach the Converse API. + """ + + model_config = ConfigDict( + alias_generator=to_camel, + populate_by_name=True, + extra='allow', + ) + + max_tokens: int | None = None + """Maximum tokens to generate. When unset, the field is left unset and + the service applies its own default cap.""" + + tool_choice: str | None = None + """Tool choice mode: ``auto``, ``required``/``any``, ``none``, or a tool name.""" + + additional_model_request_fields: dict[str, Any] | None = None + """Forwarded verbatim to the Converse API (e.g. Claude extended thinking).""" + + +class ModelDefinition(BaseModel): + """A Bedrock model to register with Genkit. + + Capabilities are inferred from the built-in registry when not provided; + unknown chat models default to multimodal + tools at the unstable stage. + """ + + name: str + """Bedrock model ID, e.g. ``anthropic.claude-sonnet-4-5-20250929-v1:0``.""" + + type: Literal['chat', 'text', 'image', 'embedding'] = 'chat' + """Routes generate calls: chat/text via Converse, image via InvokeModel.""" diff --git a/py/packages/genkit-amazon-bedrock/src/genkit_amazon_bedrock/converters.py b/py/packages/genkit-amazon-bedrock/src/genkit_amazon_bedrock/converters.py new file mode 100644 index 0000000000..d2c76ec466 --- /dev/null +++ b/py/packages/genkit-amazon-bedrock/src/genkit_amazon_bedrock/converters.py @@ -0,0 +1,720 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Pure conversion functions between Genkit types and the Bedrock Converse API. + +Ported from the Go plugin's ``generate.go``. Everything here is side-effect +free and testable without AWS: request builders return the keyword arguments +for ``client.converse(...)``, response parsers take the raw response dict. +""" + +import base64 +import json +from typing import Any + +from genkit import ( + FinishReason, + Message, + ModelRequest, + ModelResponse, + ModelUsage, + Part, + ReasoningPart, + Role, + TextPart, + ToolDefinition, + ToolRequest, + ToolRequestPart, +) +from genkit.plugin_api import GenkitError +from genkit_amazon_bedrock.config import BedrockConfig + +# Metadata keys used to round-trip Bedrock reasoning ("thinking") content back +# into a follow-up request. Bedrock returns signed and sometimes redacted +# reasoning that must be replayed verbatim on the next turn or the model +# rejects it, so both are stashed on the part metadata: the signature verbatim +# (it is a string on the wire), the redacted blob as a base64 string so the +# part stays JSON-serializable. These keys are Bedrock-specific: a generic +# reasoning part (without these) is intentionally NOT round-tripped, so +# foreign reasoning can't corrupt a Bedrock conversation. +REASONING_SIGNATURE_METADATA_KEY = 'bedrockReasoningSignature' +REDACTED_CONTENT_METADATA_KEY = 'bedrockRedactedContent' + +# Custom-part key marking a prompt cache point, mirroring the Go plugin. +CACHE_POINT_CUSTOM_KEY = 'bedrockCachePointType' +DEFAULT_CACHE_POINT_TYPE = 'default' + +IMAGE_FORMATS = { + 'image/png': 'png', + 'image/jpeg': 'jpeg', + # Common alias; Bedrock's format enum has no "jpg". + 'image/jpg': 'jpeg', + 'image/gif': 'gif', + 'image/webp': 'webp', +} + +DOCUMENT_FORMATS = { + 'application/pdf': 'pdf', + 'text/html': 'html', + 'text/plain': 'txt', + 'text/markdown': 'md', + 'text/csv': 'csv', + 'application/msword': 'doc', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx', + 'application/vnd.ms-excel': 'xls', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx', +} + +# Bedrock stopReason values → Genkit finish reasons. Unknown values map to +# OTHER instead of raising: AWS adds stop reasons without notice. +STOP_REASON_MAP = { + 'end_turn': FinishReason.STOP, + 'stop_sequence': FinishReason.STOP, + # Genkit's core loop drives tool re-entry by inspecting parts, not the + # finish reason, so a tool-use turn is a normal stop. + 'tool_use': FinishReason.STOP, + 'max_tokens': FinishReason.LENGTH, + 'model_context_window_exceeded': FinishReason.LENGTH, + 'content_filtered': FinishReason.BLOCKED, + 'guardrail_intervened': FinishReason.BLOCKED, + 'malformed_model_output': FinishReason.OTHER, + 'malformed_tool_use': FinishReason.OTHER, +} + + +def cache_point_part(cache_type: str = DEFAULT_CACHE_POINT_TYPE) -> Part: + """Builds a prompt cache-point part. + + A cache point should be inserted after a big static prompt that is reused + across multiple requests. + + Args: + cache_type: Bedrock cache-point type; only ``default`` exists today. + + Returns: + A custom Part that converts to a Converse ``cachePoint`` block. + """ + return Part.model_validate({'custom': {CACHE_POINT_CUSTOM_KEY: cache_type}}) + + +def _cache_point_type(root: Any) -> str | None: # noqa: ANN401 + custom = getattr(root, 'custom', None) + if not isinstance(custom, dict): + return None + value = custom.get(CACHE_POINT_CUSTOM_KEY) + # Compare by value: the marker arrives as a plain string after any JSON + # round-trip (resumed/serialized flows). + return value if isinstance(value, str) and value else None + + +def _metadata_bytes(metadata: dict[str, Any] | None, key: str) -> bytes | None: # noqa: ANN401 + """Reads a bytes metadata value stored in its base64-string form. + + Raw bytes are also tolerated for programmatically-built parts; invalid + base64 yields None. + """ + if not metadata: + return None + value = metadata.get(key) + if isinstance(value, bytes): + return value + if isinstance(value, str) and value: + try: + return base64.b64decode(value, validate=True) + except (ValueError, TypeError): + return None + return None + + +def _metadata_str(metadata: dict[str, Any] | None, key: str) -> str | None: # noqa: ANN401 + """Reads a string metadata value, tolerating a UTF-8 bytes form. + + The reasoning signature is a string on the Converse wire and must be + replayed verbatim, never re-encoded. + """ + if not metadata: + return None + value = metadata.get(key) + if isinstance(value, str) and value: + return value + if isinstance(value, bytes): + try: + return value.decode('utf-8') or None + except UnicodeDecodeError: + return None + return None + + +def normalize_config(config: Any) -> BedrockConfig | None: # noqa: ANN401 + """Coerces the request config into a BedrockConfig. + + Accepts a BedrockConfig, any pydantic model (e.g. the core ModelConfig the + framework validates configs into), or a plain dict — the historical shape + on resumed/serialized flows. + + Args: + config: The raw ``request.config`` value. + + Returns: + A BedrockConfig, or None when no config was given. + + Raises: + GenkitError: INVALID_ARGUMENT for unsupported config types. + """ + if config is None: + return None + if isinstance(config, BedrockConfig): + return config + if isinstance(config, dict): + return BedrockConfig.model_validate(config) + dump = getattr(config, 'model_dump', None) + if callable(dump): + return BedrockConfig.model_validate(dump(exclude_none=True)) + raise GenkitError( + message=f'bedrock: unexpected config type {type(config).__name__}, want BedrockConfig, ModelConfig, or dict', + status='INVALID_ARGUMENT', + ) + + +def _effective_max_tokens(config: BedrockConfig | None) -> int | None: + if config is None: + return None + if config.max_tokens is not None and config.max_tokens > 0: + return config.max_tokens + # Legacy/common key from the core ModelConfig surface. + if config.max_output_tokens is not None and config.max_output_tokens > 0: + return int(config.max_output_tokens) + return None + + +def build_inference_config(config: BedrockConfig | None) -> dict[str, Any] | None: # noqa: ANN401 + """Builds the Converse ``inferenceConfig`` member; None when empty.""" + if config is None: + return None + inference_config: dict[str, Any] = {} + max_tokens = _effective_max_tokens(config) + if max_tokens is not None: + inference_config['maxTokens'] = max_tokens + if config.temperature is not None: + inference_config['temperature'] = config.temperature + if config.top_p is not None: + inference_config['topP'] = config.top_p + if config.stop_sequences: + inference_config['stopSequences'] = config.stop_sequences + return inference_config or None + + +def to_bedrock_role(role: Role | str) -> str: + """Maps a Genkit role to a Converse role (only user/assistant exist). + + Genkit's TOOL role becomes ``user``: tool results travel back to Bedrock + inside a user message. + """ + if role in (Role.USER, Role.TOOL): + return 'user' + if role == Role.MODEL: + return 'assistant' + raise GenkitError(message=f'bedrock: unsupported role {role!r}', status='INVALID_ARGUMENT') + + +def _media_mime(media: Any) -> str: # noqa: ANN401 + content_type = (getattr(media, 'content_type', None) or '').strip() + url = getattr(media, 'url', '') or '' + if not content_type and url.startswith('data:'): + header = url.split(',', 1)[0].removeprefix('data:') + content_type = header.split(';', 1)[0].strip() + if not content_type: + raise GenkitError(message='bedrock: media part has no content type', status='INVALID_ARGUMENT') + return content_type.split(';', 1)[0].strip().lower() + + +def _decode_media_payload(url: str) -> bytes: + """Decodes media to raw bytes; boto3 base64-encodes them for the wire. + + Accepts a ``data:;base64,...`` URL or a bare base64 string. + Double-encoding (sending the base64 string as bytes) is the classic bug. + """ + data = (url or '').strip() + if not data: + raise GenkitError(message='bedrock: media part has empty data', status='INVALID_ARGUMENT') + # Substring search tolerates multi-parameter data-URL headers. + marker = data.find(';base64,') + if marker != -1: + payload = data[marker + len(';base64,') :].strip() + elif data.startswith('data:'): + raise GenkitError( + message="bedrock: data URL must be base64-encoded (use ';base64,' prefix)", + status='INVALID_ARGUMENT', + ) + elif data.startswith(('http://', 'https://')): + raise GenkitError( + message='bedrock: remote URLs are not supported; use a data URL or base64-encoded data', + status='INVALID_ARGUMENT', + ) + else: + payload = data + try: + return base64.b64decode(payload, validate=True) + except (ValueError, TypeError) as e: + raise GenkitError(message=f'bedrock: decode base64 media: {e}', status='INVALID_ARGUMENT') from e + + +def media_to_block(root: Any) -> dict[str, Any]: # noqa: ANN401 + """Converts a media part to an image or document content block.""" + media = root.media + mime = _media_mime(media) + payload = _decode_media_payload(media.url) + # Document formats are checked before image formats, matching Go. + document_format = DOCUMENT_FORMATS.get(mime) + if document_format is not None: + return { + 'document': { + 'format': document_format, + 'name': 'document', + 'source': {'bytes': payload}, + } + } + image_format = IMAGE_FORMATS.get(mime) + if image_format is not None: + return {'image': {'format': image_format, 'source': {'bytes': payload}}} + raise GenkitError( + message=( + f'bedrock: unsupported media MIME type {mime!r} ' + '(must be png/jpeg/gif/webp or one of pdf/csv/doc/docx/xls/xlsx/html/txt/md)' + ), + status='INVALID_ARGUMENT', + ) + + +def _tool_response_text(output: Any) -> str: # noqa: ANN401 + if output is None: + return '' + if isinstance(output, str): + return output + try: + return json.dumps(output) + except (TypeError, ValueError) as e: + raise GenkitError(message=f'bedrock: marshal tool response: {e}', status='INVALID_ARGUMENT') from e + + +def _reasoning_part_to_blocks(root: Any) -> list[dict[str, Any]]: # noqa: ANN401 + """Converts a reasoning part back to Converse reasoningContent blocks. + + Only Bedrock-originated reasoning (carrying the signature and/or redacted + metadata) is emitted; a generic reasoning part produces no blocks so it + cannot corrupt the follow-up request. + """ + metadata = getattr(root, 'metadata', None) + blocks: list[dict[str, Any]] = [] + redacted = _metadata_bytes(metadata, REDACTED_CONTENT_METADATA_KEY) + if redacted: + blocks.append({'reasoningContent': {'redactedContent': redacted}}) + signature = _metadata_str(metadata, REASONING_SIGNATURE_METADATA_KEY) + text = getattr(root, 'reasoning', None) or '' + if text and signature: + blocks.append({'reasoningContent': {'reasoningText': {'text': text, 'signature': signature}}}) + return blocks + + +def _tool_use_id(ref: str | None, label: str) -> str: + """Bedrock's toolUseId rejects empty strings, so a missing ref cannot be sent.""" + if not ref: + raise GenkitError( + message=f'bedrock: {label} requires a ref to send as toolUseId', + status='INVALID_ARGUMENT', + ) + return ref + + +def _part_to_blocks(part: Part | Any) -> list[dict[str, Any]]: # noqa: ANN401 + """Converts one Genkit part to Converse content blocks. + + Unknown part kinds are silently dropped, matching Go's request-side + posture (the response side fails loud instead). + """ + root = part.root if isinstance(part, Part) else part + if getattr(root, 'media', None) is not None: + return [media_to_block(root)] + tool_request = getattr(root, 'tool_request', None) + if tool_request is not None: + return [ + { + 'toolUse': { + 'toolUseId': _tool_use_id(tool_request.ref, 'tool request'), + 'name': tool_request.name, + 'input': tool_request.input, + } + } + ] + tool_response = getattr(root, 'tool_response', None) + if tool_response is not None: + return [ + { + 'toolResult': { + 'toolUseId': _tool_use_id(tool_response.ref, 'tool response'), + 'content': [{'text': _tool_response_text(tool_response.output)}], + 'status': 'success', + } + } + ] + cache_type = _cache_point_type(root) + if cache_type is not None: + return [{'cachePoint': {'type': cache_type}}] + # `is not None`: a redacted-only reasoning part has reasoning == ''. + if getattr(root, 'reasoning', None) is not None: + return _reasoning_part_to_blocks(root) + if getattr(root, 'text', None) is not None: + return [{'text': root.text}] + return [] + + +def _system_blocks(message: Message | Any) -> list[dict[str, Any]]: # noqa: ANN401 + """System messages keep only text and cache points; the rest is dropped.""" + blocks: list[dict[str, Any]] = [] + for part in message.content or []: + root = part.root if isinstance(part, Part) else part + cache_type = _cache_point_type(root) + if cache_type is not None: + blocks.append({'cachePoint': {'type': cache_type}}) + # Truthiness, not `is not None`: Bedrock rejects empty system text. + elif getattr(root, 'text', None): + blocks.append({'text': root.text}) + return blocks + + +def convert_messages( + messages: list[Message] | None, +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + """Splits Genkit messages into Converse messages and top-level system blocks. + + Genkit's SYSTEM role becomes the separate top-level ``system`` array, not + a conversation message. Messages that convert to zero blocks are dropped + entirely — Bedrock rejects empty content arrays. + + Returns: + A ``(messages, system)`` tuple of Converse-shaped dicts. + """ + converse_messages: list[dict[str, Any]] = [] + system: list[dict[str, Any]] = [] + for message in messages or []: + if message is None: + continue + if message.role == Role.SYSTEM: + system.extend(_system_blocks(message)) + continue + # Validate the role before converting parts, like Go, so an + # unsupported role errors even when its message converts to nothing. + role = to_bedrock_role(message.role) + blocks: list[dict[str, Any]] = [] + for part in message.content or []: + if part is None: + continue + blocks.extend(_part_to_blocks(part)) + if not blocks: + continue + converse_messages.append({'role': role, 'content': blocks}) + return converse_messages, system + + +def _normalize_tool_schema(schema: Any) -> dict[str, Any]: # noqa: ANN401 + """Normalizes a tool input schema for ``toolSpec.inputSchema.json``. + + Bedrock requires an input schema; a missing one becomes the empty object + schema. Injects ``type``/``properties``/``$schema`` defaults like Go. + """ + if schema is None: + normalized: dict[str, Any] = {'type': 'object', 'properties': {}} + elif isinstance(schema, dict): + normalized = dict(schema) + elif isinstance(schema, (str, bytes)): + try: + parsed = json.loads(schema) + except (ValueError, TypeError): + return {'type': 'object', 'properties': {}} + normalized = parsed if isinstance(parsed, dict) else {'type': 'object', 'properties': {}} + else: + return {'type': 'object', 'properties': {}} + normalized.setdefault('type', 'object') + if normalized.get('type') == 'object': + normalized.setdefault('properties', {}) + normalized.setdefault('$schema', 'http://json-schema.org/draft-07/schema#') + return normalized + + +def to_bedrock_tool(tool: ToolDefinition | None) -> dict[str, Any]: # noqa: ANN401 + """Converts a Genkit tool definition to a Converse toolSpec.""" + if tool is None: + raise GenkitError(message='bedrock: tool definition required', status='INVALID_ARGUMENT') + if not tool.name: + raise GenkitError(message='bedrock: tool name required', status='INVALID_ARGUMENT') + tool_spec: dict[str, Any] = { + 'name': tool.name, + 'inputSchema': {'json': _normalize_tool_schema(tool.input_schema)}, + } + if tool.description: + # Omitted when empty: description is optional, but Bedrock rejects ''. + tool_spec['description'] = tool.description + return {'toolSpec': tool_spec} + + +def _to_bedrock_tool_choice(tool_choice: str, tools: list[ToolDefinition]) -> dict[str, Any]: # noqa: ANN401 + if tool_choice in ('', 'auto'): + return {'auto': {}} + if tool_choice in ('required', 'any'): + return {'any': {}} + for tool in tools: + if tool is not None and tool.name == tool_choice: + return {'tool': {'name': tool_choice}} + raise GenkitError( + message=f'bedrock: tool_choice {tool_choice!r} does not match any declared tool', + status='INVALID_ARGUMENT', + ) + + +def build_converse_request(model_id: str, request: ModelRequest[Any]) -> dict[str, Any]: # noqa: ANN401 + """Builds the keyword arguments for ``client.converse(...)``. + + The model ID is sent verbatim — inference-profile prefixes and ARNs are + preserved; only capability lookup ever strips them. + + Args: + model_id: Bedrock model ID, inference-profile ID, or ARN. + request: The Genkit model request. + + Returns: + Keyword arguments for the Converse call. + """ + if request is None: + raise GenkitError(message='bedrock: model request is nil', status='INVALID_ARGUMENT') + config = normalize_config(request.config) + messages, system = convert_messages(request.messages) + + tools = [tool for tool in (request.tools or []) if tool is not None] if request.tools else [] + # When using tools, AWS Bedrock requires that the conversation doesn't + # end with an assistant message. + if request.tools and messages and messages[-1]['role'] == 'assistant': + messages = messages[:-1] + + kwargs: dict[str, Any] = {'modelId': model_id, 'messages': messages} + if system: + kwargs['system'] = system + + inference_config = build_inference_config(config) + if inference_config: + kwargs['inferenceConfig'] = inference_config + + if config is not None and config.additional_model_request_fields: + # Forwarded verbatim (e.g. Claude extended thinking budgets). + kwargs['additionalModelRequestFields'] = config.additional_model_request_fields + + if request.tools: + tool_choice = _requested_tool_choice(request, config) + # "none" means omit toolConfig entirely — Bedrock has no none mode. + if tool_choice == 'none': + return kwargs + tool_config: dict[str, Any] = {'tools': [to_bedrock_tool(tool) for tool in request.tools]} + if tool_choice: + tool_config['toolChoice'] = _to_bedrock_tool_choice(tool_choice, tools) + kwargs['toolConfig'] = tool_config + return kwargs + + +def _requested_tool_choice(request: ModelRequest[Any], config: BedrockConfig | None) -> str: + # The Bedrock-specific config wins over the core request field so callers + # can name a specific tool, which the core enum cannot express. + if config is not None and config.tool_choice: + return config.tool_choice + if request.tool_choice: + return str(request.tool_choice) + return '' + + +def _coerce_value(value: Any, schema: Any) -> Any: # noqa: ANN401 + """Coerces a tool-input value toward its declared schema type. + + Models occasionally return numbers or booleans as strings and floats for + integers; coerce like Go instead of failing tool dispatch. + """ + if not isinstance(schema, dict): + return value + schema_type = schema.get('type') + if schema_type in ('number', 'integer') and isinstance(value, str): + try: + number = float(value) + except ValueError: + return value + return int(number) if schema_type == 'integer' else number + if schema_type == 'integer' and isinstance(value, float): + return int(value) + if schema_type == 'number' and isinstance(value, int) and not isinstance(value, bool): + return float(value) + if schema_type == 'string' and isinstance(value, (int, float)) and not isinstance(value, bool): + # Go coerces wire numbers to their string form; a raw int fails dispatch. + return str(value) + if schema_type == 'boolean' and isinstance(value, str): + # Matches Go's strconv.ParseBool vocabulary. + lowered = value.strip().lower() + if lowered in ('true', 't', '1'): + return True + if lowered in ('false', 'f', '0'): + return False + return value + if schema_type == 'array' and isinstance(value, list): + return [_coerce_value(item, schema.get('items')) for item in value] + if schema_type == 'object' and isinstance(value, dict): + return _coerce_map(value, schema) + return value + + +def _coerce_map(value: dict[str, Any], schema: dict[str, Any]) -> dict[str, Any]: # noqa: ANN401 + # Only object schemas coerce, matching Go; non-object schemas pass through. + if schema.get('type') != 'object': + return value + properties = schema.get('properties') + if not isinstance(properties, dict): + return value + # Keys without a schema are kept as-is. + return {key: _coerce_value(item, properties.get(key)) for key, item in value.items()} + + +def _coerce_tool_input(name: str, value: Any, tools: list[ToolDefinition] | None) -> Any: # noqa: ANN401 + if not isinstance(value, dict): + return value + for tool in tools or []: + if tool is not None and tool.name == name and isinstance(tool.input_schema, dict): + return _coerce_map(value, tool.input_schema) + return value + + +def _reasoning_block_to_part(block: dict[str, Any]) -> Part | None: # noqa: ANN401 + reasoning_text = block.get('reasoningText') + if reasoning_text is not None: + # Both wire shapes occur: {'text': ...} and a bare string. + if isinstance(reasoning_text, str): + text, signature = reasoning_text, None + else: + text = reasoning_text.get('text') or '' + signature = reasoning_text.get('signature') + if not text and not signature: + return None + return _bedrock_reasoning_part(text, signature, None) + redacted = block.get('redactedContent') + if redacted is not None: + if not redacted: + return None + return _bedrock_reasoning_part('', None, redacted) + raise GenkitError( + message=f'bedrock: unhandled reasoning content variant {sorted(block.keys())!r}', + status='INTERNAL', + ) + + +def _bedrock_reasoning_part(text: str, signature: str | None, redacted: bytes | None) -> Part: + metadata: dict[str, Any] = {} + if signature: + # Also stored under the generic key so framework-level consumers see it. + metadata['signature'] = signature + metadata[REASONING_SIGNATURE_METADATA_KEY] = signature + if redacted: + # Base64 string, not raw bytes: part metadata must stay JSON-serializable. + metadata[REDACTED_CONTENT_METADATA_KEY] = base64.b64encode(redacted).decode('ascii') + return Part(root=ReasoningPart(reasoning=text, metadata=metadata or None)) + + +def content_blocks_to_parts( + blocks: list[dict[str, Any]], + tools: list[ToolDefinition] | None = None, +) -> list[Part]: + """Converts Converse response content blocks to Genkit parts. + + Unlike the request side, unknown response blocks fail loud: silently + dropping model output would corrupt conversations. + """ + parts: list[Part] = [] + for block in blocks: + if 'text' in block: + parts.append(Part(root=TextPart(text=block['text']))) + elif 'toolUse' in block: + tool_use = block['toolUse'] + tool_input = tool_use.get('input') + if tool_input is None: + tool_input = {} + parts.append( + Part( + root=ToolRequestPart( + tool_request=ToolRequest( + ref=tool_use.get('toolUseId'), + name=tool_use.get('name') or '', + input=_coerce_tool_input(tool_use.get('name') or '', tool_input, tools), + ) + ) + ) + ) + elif 'reasoningContent' in block: + part = _reasoning_block_to_part(block['reasoningContent']) + if part is not None: + parts.append(part) + else: + raise GenkitError( + message=f'bedrock: unhandled response content variant {sorted(block.keys())!r}', + status='INTERNAL', + ) + return parts + + +def map_finish_reason(stop_reason: str | None) -> FinishReason: + """Maps a Bedrock stopReason to a Genkit finish reason (unknown → OTHER).""" + return STOP_REASON_MAP.get(stop_reason or '', FinishReason.OTHER) + + +def usage_from_response(usage: dict[str, Any] | None) -> ModelUsage | None: # noqa: ANN401 + """Maps Converse token usage; totals are trusted verbatim from AWS. + + Only ``cacheReadInputTokens`` surfaces (as ``cached_content_tokens``), + matching the Go plugin; cache-write tokens are unreported. + """ + if usage is None: + return None + return ModelUsage( + input_tokens=usage.get('inputTokens'), + output_tokens=usage.get('outputTokens'), + total_tokens=usage.get('totalTokens'), + cached_content_tokens=usage.get('cacheReadInputTokens'), + ) + + +def to_model_response(response: dict[str, Any] | None, request: ModelRequest[Any]) -> ModelResponse: # noqa: ANN401 + """Converts a raw Converse response to a Genkit ModelResponse.""" + if response is None: + raise GenkitError(message='bedrock: converse response is nil', status='INTERNAL') + output = response.get('output') or {} + message = output.get('message') + if output and message is None: + raise GenkitError( + message=f'bedrock: unexpected output variant {sorted(output.keys())!r}', + status='INTERNAL', + ) + parts = content_blocks_to_parts((message or {}).get('content') or [], request.tools) + if not parts: + # Guardrail-blocked responses have no content; return a well-formed + # empty message rather than erroring. + parts = [Part(root=TextPart(text=''))] + return ModelResponse( + message=Message(role=Role.MODEL, content=parts), + finish_reason=map_finish_reason(response.get('stopReason')), + usage=usage_from_response(response.get('usage')), + request=request, + ) diff --git a/py/packages/genkit-amazon-bedrock/src/genkit_amazon_bedrock/model_info.py b/py/packages/genkit-amazon-bedrock/src/genkit_amazon_bedrock/model_info.py new file mode 100644 index 0000000000..a152168c76 --- /dev/null +++ b/py/packages/genkit-amazon-bedrock/src/genkit_amazon_bedrock/model_info.py @@ -0,0 +1,191 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Model capability registry for the Amazon Bedrock plugin. + +Ported from the Go plugin's ``models.go``. Capabilities are keyed by base +Bedrock model ID; cross-region inference-profile prefixes are stripped for +lookup only - the full original model ID is always sent to Bedrock untouched. +Unknown chat/text models fall back to modern Converse defaults (multimodal + +tools) at the unstable stage, so newer or inference-profile-only models remain +callable without a plugin release. +""" + +from typing import Literal, NamedTuple + +from genkit import Constrained, ModelInfo, Stage, Supports + +INFERENCE_PROFILE_PREFIXES = ( + 'global.', + 'us-gov.', + 'us.', + 'eu.', + 'jp.', + 'apac.', + 'au.', +) + + +class ModelCapability(NamedTuple): + """Capabilities of a Bedrock chat/text model.""" + + multimodal: bool + tools: bool + + +MODEL_CAPABILITIES: dict[str, ModelCapability] = { + # Anthropic Claude 3 models + 'anthropic.claude-3-haiku-20240307-v1:0': ModelCapability(multimodal=True, tools=True), + 'anthropic.claude-3-sonnet-20240229-v1:0': ModelCapability(multimodal=True, tools=True), + 'anthropic.claude-3-opus-20240229-v1:0': ModelCapability(multimodal=True, tools=True), + 'anthropic.claude-3-5-haiku-20241022-v1:0': ModelCapability(multimodal=False, tools=True), + 'anthropic.claude-3-5-sonnet-20240620-v1:0': ModelCapability(multimodal=True, tools=True), + 'anthropic.claude-3-5-sonnet-20241022-v2:0': ModelCapability(multimodal=True, tools=True), + 'anthropic.claude-3-7-sonnet-20250219-v1:0': ModelCapability(multimodal=True, tools=True), + # Anthropic Claude 4/4.5/4.6 models + 'anthropic.claude-haiku-4-5-20251001-v1:0': ModelCapability(multimodal=True, tools=True), + 'anthropic.claude-opus-4-1-20250805-v1:0': ModelCapability(multimodal=True, tools=True), + 'anthropic.claude-opus-4-20250514-v1:0': ModelCapability(multimodal=True, tools=True), + 'anthropic.claude-sonnet-4-20250514-v1:0': ModelCapability(multimodal=True, tools=True), + 'anthropic.claude-sonnet-4-5-20250929-v1:0': ModelCapability(multimodal=True, tools=True), + 'anthropic.claude-opus-4-5-20251101-v1:0': ModelCapability(multimodal=True, tools=True), + 'anthropic.claude-sonnet-4-6': ModelCapability(multimodal=True, tools=True), + 'anthropic.claude-opus-4-6-v1': ModelCapability(multimodal=True, tools=True), + # Provisioned-throughput variants (28k/48k/200k context) + 'anthropic.claude-3-haiku-20240307-v1:0:48k': ModelCapability(multimodal=True, tools=True), + 'anthropic.claude-3-haiku-20240307-v1:0:200k': ModelCapability(multimodal=True, tools=True), + 'anthropic.claude-3-sonnet-20240229-v1:0:28k': ModelCapability(multimodal=True, tools=True), + 'anthropic.claude-3-sonnet-20240229-v1:0:200k': ModelCapability(multimodal=True, tools=True), + # Amazon Nova models + 'amazon.nova-micro-v1:0': ModelCapability(multimodal=False, tools=True), + 'amazon.nova-lite-v1:0': ModelCapability(multimodal=True, tools=True), + 'amazon.nova-pro-v1:0': ModelCapability(multimodal=True, tools=True), + 'amazon.nova-premier-v1:0': ModelCapability(multimodal=True, tools=True), + # Cohere Command models + 'cohere.command-r-v1:0': ModelCapability(multimodal=False, tools=True), + 'cohere.command-r-plus-v1:0': ModelCapability(multimodal=False, tools=True), + # Mistral models + 'mistral.mistral-large-2402-v1:0': ModelCapability(multimodal=False, tools=True), + 'mistral.mistral-large-2407-v1:0': ModelCapability(multimodal=False, tools=True), + 'mistral.mistral-small-2402-v1:0': ModelCapability(multimodal=False, tools=True), + 'mistral.pixtral-large-2502-v1:0': ModelCapability(multimodal=True, tools=True), + # AI21 Labs Jamba models + 'ai21.jamba-1-5-large-v1:0': ModelCapability(multimodal=False, tools=True), + 'ai21.jamba-1-5-mini-v1:0': ModelCapability(multimodal=False, tools=True), + # Meta Llama models + 'meta.llama3-8b-instruct-v1:0': ModelCapability(multimodal=False, tools=True), + 'meta.llama3-70b-instruct-v1:0': ModelCapability(multimodal=False, tools=True), + 'meta.llama3-1-8b-instruct-v1:0': ModelCapability(multimodal=False, tools=True), + 'meta.llama3-1-70b-instruct-v1:0': ModelCapability(multimodal=False, tools=True), + 'meta.llama3-1-405b-instruct-v1:0': ModelCapability(multimodal=False, tools=True), + 'meta.llama3-2-1b-instruct-v1:0': ModelCapability(multimodal=False, tools=True), + 'meta.llama3-2-3b-instruct-v1:0': ModelCapability(multimodal=False, tools=True), + 'meta.llama3-2-11b-instruct-v1:0': ModelCapability(multimodal=True, tools=True), + 'meta.llama3-2-90b-instruct-v1:0': ModelCapability(multimodal=True, tools=True), + 'meta.llama3-3-70b-instruct-v1:0': ModelCapability(multimodal=False, tools=True), + 'meta.llama4-maverick-17b-instruct-v1:0': ModelCapability(multimodal=True, tools=True), + 'meta.llama4-scout-17b-instruct-v1:0': ModelCapability(multimodal=True, tools=True), + # DeepSeek models + 'deepseek.r1-v1:0': ModelCapability(multimodal=False, tools=True), + # Writer models + 'writer.palmyra-x4-v1:0': ModelCapability(multimodal=False, tools=True), + 'writer.palmyra-x5-v1:0': ModelCapability(multimodal=False, tools=True), + # TwelveLabs models + 'twelvelabs.pegasus-1-2-v1:0': ModelCapability(multimodal=False, tools=True), +} + + +def strip_inference_profile_prefix(model_id: str) -> str: + """Strips a cross-region inference-profile prefix from a model ID. + + Used for capability lookup only; requests always carry the original ID. + Full Bedrock ARNs (foundation-model, inference-profile) are reduced to + their resource ID first, across all partitions (``arn:aws:``, + ``arn:aws-us-gov:``, ``arn:aws-cn:``). + + Args: + model_id: Bedrock model ID, inference-profile ID, or full ARN. + + Returns: + The base model ID without the inference-profile prefix. + """ + if model_id.startswith('arn:'): + model_id = model_id.rsplit('/', 1)[-1] + for prefix in INFERENCE_PROFILE_PREFIXES: + if model_id.startswith(prefix): + return model_id.removeprefix(prefix) + return model_id + + +def get_model_info( + model_name: str, + model_type: Literal['chat', 'text', 'image', 'embedding'] = 'chat', +) -> ModelInfo: + """Infers Genkit model info for a Bedrock model. + + Args: + model_name: Bedrock model ID or inference-profile ID. + model_type: Routing type for the model. + + Returns: + ModelInfo with capabilities from the registry, or modern Converse + defaults at the unstable stage for unknown chat/text models. + """ + if model_type == 'image': + return ModelInfo( + label=model_name, + stage=Stage.STABLE, + supports=Supports( + multiturn=False, + tools=False, + tool_choice=False, + system_role=False, + media=True, + constrained=Constrained.NONE, + ), + ) + + if model_type == 'embedding': + return ModelInfo( + label=model_name, + stage=Stage.STABLE, + supports=Supports( + multiturn=False, + tools=False, + tool_choice=False, + system_role=False, + media=False, + constrained=Constrained.NONE, + ), + ) + + capability = MODEL_CAPABILITIES.get(strip_inference_profile_prefix(model_name)) + stage = Stage.STABLE if capability is not None else Stage.UNSTABLE + if capability is None: + capability = ModelCapability(multimodal=True, tools=True) + + return ModelInfo( + label=model_name, + stage=stage, + supports=Supports( + multiturn=True, + tools=capability.tools, + tool_choice=capability.tools, + system_role=True, + media=capability.multimodal, + constrained=Constrained.NONE, + ), + ) diff --git a/py/packages/genkit-amazon-bedrock/src/genkit_amazon_bedrock/models.py b/py/packages/genkit-amazon-bedrock/src/genkit_amazon_bedrock/models.py new file mode 100644 index 0000000000..cf8061a490 --- /dev/null +++ b/py/packages/genkit-amazon-bedrock/src/genkit_amazon_bedrock/models.py @@ -0,0 +1,131 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Bedrock model action implementation (Converse API).""" + +from typing import Any, Protocol + +from botocore.exceptions import ( + BotoCoreError, + ClientError, + ConnectTimeoutError, + EndpointConnectionError, + NoCredentialsError, + NoRegionError, + ParamValidationError, + PartialCredentialsError, + ReadTimeoutError, +) + +from genkit import ModelRequest, ModelResponse, ModelResponseChunk, Role +from genkit.plugin_api import ActionRunContext, GenkitError, StatusName +from genkit_amazon_bedrock.converters import build_converse_request, to_model_response + + +class ConverseTransport(Protocol): + """Structural contract for the transport seam (see ``transport.py``).""" + + async def converse(self, **kwargs: Any) -> dict[str, Any]: # noqa: ANN401 + """Calls the Converse API and returns the raw response dict.""" + ... + + +# AWS error codes → Genkit statuses; anything unlisted maps to UNKNOWN. +_ERROR_CODE_STATUS: dict[str, StatusName] = { + 'ThrottlingException': 'RESOURCE_EXHAUSTED', + 'TooManyRequestsException': 'RESOURCE_EXHAUSTED', + 'ServiceQuotaExceededException': 'RESOURCE_EXHAUSTED', + 'ValidationException': 'INVALID_ARGUMENT', + 'AccessDeniedException': 'PERMISSION_DENIED', + 'UnrecognizedClientException': 'UNAUTHENTICATED', + 'ExpiredTokenException': 'UNAUTHENTICATED', + 'ResourceNotFoundException': 'NOT_FOUND', + 'ModelTimeoutException': 'DEADLINE_EXCEEDED', + 'ModelNotReadyException': 'UNAVAILABLE', + 'ServiceUnavailableException': 'UNAVAILABLE', + 'ModelErrorException': 'INTERNAL', +} + + +# Client-side botocore failures never reach the service, so they carry no error +# code; map the exception type instead. Anything unlisted stays UNKNOWN. +_BOTOCORE_ERROR_STATUS: tuple[tuple[type[BotoCoreError], StatusName], ...] = ( + (ParamValidationError, 'INVALID_ARGUMENT'), + (NoCredentialsError, 'UNAUTHENTICATED'), + (PartialCredentialsError, 'UNAUTHENTICATED'), + (NoRegionError, 'FAILED_PRECONDITION'), + (ReadTimeoutError, 'DEADLINE_EXCEEDED'), + (ConnectTimeoutError, 'DEADLINE_EXCEEDED'), + (EndpointConnectionError, 'UNAVAILABLE'), +) + + +def _from_client_error(error: ClientError) -> GenkitError: + error_info: dict[str, Any] = error.response.get('Error') or {} + code = error_info.get('Code') or '' + message = error_info.get('Message') or str(error) + return GenkitError( + message=f'bedrock converse failed: {code}: {message}' if code else f'bedrock converse failed: {message}', + status=_ERROR_CODE_STATUS.get(code, 'UNKNOWN'), + ) + + +def _from_botocore_error(error: BotoCoreError) -> GenkitError: + status: StatusName = 'UNKNOWN' + for error_type, mapped in _BOTOCORE_ERROR_STATUS: + if isinstance(error, error_type): + status = mapped + break + return GenkitError(message=f'bedrock converse failed: {error}', status=status) + + +class BedrockModel: + """Handles a generate call for one Bedrock chat/text model.""" + + def __init__(self, model_id: str, transport: ConverseTransport) -> None: + """Initializes the model handler. + + Args: + model_id: Bedrock model ID, inference-profile ID, or ARN, sent to + the Converse API verbatim. + transport: The shared transport seam owning the boto3 client. + """ + self._model_id = model_id + self._transport = transport + + async def generate(self, request: ModelRequest[Any], ctx: ActionRunContext | None = None) -> ModelResponse: + """Runs a non-streaming Converse call. + + Args: + request: The Genkit model request. + ctx: Action run context; when a streaming callback is attached the + full response is emitted as a single chunk until + ConverseStream lands in a later slice. + + Returns: + The converted model response. + """ + converse_kwargs = build_converse_request(self._model_id, request) + try: + response = await self._transport.converse(**converse_kwargs) + except ClientError as e: + raise _from_client_error(e) from e + except BotoCoreError as e: + raise _from_botocore_error(e) from e + model_response = to_model_response(response, request) + if ctx is not None and ctx.is_streaming and model_response.message is not None: + ctx.send_chunk(ModelResponseChunk(role=Role.MODEL, index=0, content=model_response.message.content)) + return model_response diff --git a/py/packages/genkit-amazon-bedrock/src/genkit_amazon_bedrock/plugin.py b/py/packages/genkit-amazon-bedrock/src/genkit_amazon_bedrock/plugin.py new file mode 100644 index 0000000000..d00e935006 --- /dev/null +++ b/py/packages/genkit-amazon-bedrock/src/genkit_amazon_bedrock/plugin.py @@ -0,0 +1,201 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Amazon Bedrock plugin for Genkit. + +Registers Bedrock-hosted models (Anthropic Claude, Amazon Nova, Meta Llama, +Mistral, Cohere, and others), embedders (Titan, Cohere, Nova), image +generators, and the Cohere reranker as Genkit actions. Text generation uses +the Bedrock Converse and ConverseStream APIs; embeddings, image generation, +and reranking use InvokeModel. + +Ported from the Go plugin (genkit-ai/aws-bedrock-go-plugin). +""" + +from typing import TYPE_CHECKING + +from genkit import ModelRequest, ModelResponse +from genkit.model import model_action_metadata +from genkit.plugin_api import ( + Action, + ActionKind, + ActionMetadata, + ActionRunContext, + Plugin, + to_json_schema, +) +from genkit_amazon_bedrock.config import ( + DEFAULT_CONNECT_TIMEOUT, + DEFAULT_MAX_POOL_CONNECTIONS, + DEFAULT_MAX_RETRIES, + DEFAULT_READ_TIMEOUT, + BedrockConfig, + ModelDefinition, +) +from genkit_amazon_bedrock.model_info import get_model_info +from genkit_amazon_bedrock.models import BedrockModel +from genkit_amazon_bedrock.transport import BedrockTransport + +if TYPE_CHECKING: + import boto3.session + +BEDROCK_PLUGIN_NAME = 'bedrock' + + +def bedrock_name(name: str) -> str: + """Fully qualified Genkit action name for a Bedrock model. + + Args: + name: Bedrock model ID. + + Returns: + The namespaced action name, e.g. ``bedrock/anthropic.claude-...``. + """ + return f'{BEDROCK_PLUGIN_NAME}/{name}' + + +class Bedrock(Plugin): + """Amazon Bedrock plugin for Genkit.""" + + name = BEDROCK_PLUGIN_NAME + + def __init__( + self, + region: str | None = None, + max_retries: int = DEFAULT_MAX_RETRIES, + read_timeout: float = DEFAULT_READ_TIMEOUT, + connect_timeout: float = DEFAULT_CONNECT_TIMEOUT, + max_pool_connections: int = DEFAULT_MAX_POOL_CONNECTIONS, + session: 'boto3.session.Session | None' = None, + models: list[ModelDefinition] | None = None, + embedders: list[str] | None = None, + ) -> None: + """Initializes the Bedrock plugin. + + Args: + region: AWS region. Defaults to the SDK resolution chain + (``AWS_REGION``, ``AWS_DEFAULT_REGION``, ``~/.aws/config``); + initialization fails loudly when no region resolves rather + than silently picking one. + max_retries: Retry limit for Bedrock API calls. + read_timeout: Socket read timeout in seconds (not a whole-call + deadline; long generations must not be killed mid-flight). + connect_timeout: Socket connect timeout in seconds. + max_pool_connections: HTTP connection pool size. + session: Optional pre-configured ``boto3.session.Session`` for custom + credentials or advanced SDK wiring. + models: Bedrock models to register. Models not listed can still be + resolved dynamically by namespaced name. + embedders: Embedding model IDs to register (Titan, Cohere, Nova). + """ + self.region = region + self.max_retries = max_retries + self.read_timeout = read_timeout + self.connect_timeout = connect_timeout + self.max_pool_connections = max_pool_connections + self._session = session + self.models = models or [] + self.embedders = embedders or [] + self._transport = BedrockTransport( + region=region, + max_retries=max_retries, + read_timeout=read_timeout, + connect_timeout=connect_timeout, + max_pool_connections=max_pool_connections, + session=session, + ) + + async def init(self) -> list[Action]: + """Initialize plugin. + + Builds the shared client so misconfiguration (e.g. no resolvable AWS + region) fails at startup instead of on the first model call. + + Returns: + Empty list (actions are lazily created via ``resolve``). + """ + await self._transport.ensure_client() + return [] + + async def resolve(self, action_type: ActionKind, name: str) -> Action | None: + """Resolve an action by namespaced name. + + Any model ID resolves — the Bedrock catalogue includes arbitrary + inference profiles and ARNs and can never be fully enumerated. + + Args: + action_type: The kind of action to resolve. + name: The namespaced action name. + + Returns: + Action object if resolvable, None otherwise. + """ + if action_type != ActionKind.MODEL: + return None + model_id = name.removeprefix(f'{BEDROCK_PLUGIN_NAME}/') + model_type = self._configured_model_type(model_id) + if model_type not in ('chat', 'text'): + # Image generation lands in a later slice. + return None + return self._create_model_action(name, model_id) + + def _configured_model_type(self, model_id: str) -> str: + for definition in self.models: + if definition.name == model_id: + return definition.type + return 'chat' + + def _create_model_action(self, name: str, model_id: str) -> Action: + model_info = get_model_info(model_id) + + async def _generate(request: ModelRequest, ctx: ActionRunContext) -> ModelResponse: + model = BedrockModel(model_id=model_id, transport=self._transport) + return await model.generate(request, ctx) + + return Action( + kind=ActionKind.MODEL, + name=bedrock_name(model_id), + fn=_generate, + metadata={ + 'model': { + 'label': model_info.label, + 'stage': model_info.stage.value if model_info.stage else None, + 'supports': ( + model_info.supports.model_dump(by_alias=True, exclude_none=True) if model_info.supports else {} + ), + 'customOptions': to_json_schema(BedrockConfig), + }, + }, + ) + + async def list_actions(self) -> list[ActionMetadata]: + """List configured Bedrock models. + + Only explicitly configured models are listed; the catalogue itself is + open-ended, and any model ID still resolves on demand. + + Returns: + ActionMetadata for each configured chat model. + """ + return [ + model_action_metadata( + name=bedrock_name(definition.name), + info=get_model_info(definition.name, definition.type).model_dump(by_alias=True, exclude_none=True), + config_schema=BedrockConfig, + ) + for definition in self.models + if definition.type in ('chat', 'text') + ] diff --git a/py/packages/genkit-amazon-bedrock/src/genkit_amazon_bedrock/py.typed b/py/packages/genkit-amazon-bedrock/src/genkit_amazon_bedrock/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/py/packages/genkit-amazon-bedrock/src/genkit_amazon_bedrock/transport.py b/py/packages/genkit-amazon-bedrock/src/genkit_amazon_bedrock/transport.py new file mode 100644 index 0000000000..cd36e6434d --- /dev/null +++ b/py/packages/genkit-amazon-bedrock/src/genkit_amazon_bedrock/transport.py @@ -0,0 +1,143 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Transport seam for the Amazon Bedrock plugin. + +Every boto3 call goes through this module. boto3 is synchronous, so calls are +bridged onto worker threads with ``asyncio.to_thread`` to keep the event loop +unblocked for the seconds-to-minutes an LLM call takes. Keeping the whole SDK +surface behind one seam lets us swap in AWS's official async SDK once it +matures without touching converters or models. +""" + +import asyncio +import os +import threading +from typing import TYPE_CHECKING, Any + +from genkit.plugin_api import GenkitError + +if TYPE_CHECKING: + import boto3.session + +NO_REGION_MESSAGE = ( + 'bedrock: no AWS region resolved; set Bedrock(region=...), AWS_REGION, ' + 'AWS_DEFAULT_REGION, or a region in ~/.aws/config' +) + + +class BedrockTransport: + """Owns the shared bedrock-runtime client and the sync-to-async bridge. + + The sync boto3 client is not bound to an event loop (unlike async SDK + clients), so one client instance safely serves both the application loop + and the Dev UI reflection loop; boto3 clients are thread-safe for calls, + only creation needs the lock. + """ + + def __init__( + self, + *, + region: str | None = None, + max_retries: int, + read_timeout: float, + connect_timeout: float, + max_pool_connections: int, + session: 'boto3.session.Session | None' = None, + ) -> None: + """Initializes the transport. + + Args: + region: AWS region; falls back to the SDK resolution chain. + max_retries: Retry limit for Bedrock API calls. + read_timeout: Socket read timeout in seconds. Deliberately not a + whole-call deadline: long generations stream for minutes and + must not be killed mid-flight. + connect_timeout: Socket connect timeout in seconds. + max_pool_connections: HTTP connection pool size, raised off the + botocore default of 10 so the pool is never the bottleneck. + Concurrency is bounded first by the event loop's default + thread-pool executor, which ``asyncio.to_thread`` dispatches to. + session: Optional pre-configured ``boto3.session.Session`` for + custom credentials or advanced SDK wiring. + """ + self._region = region + self._max_retries = max_retries + self._read_timeout = read_timeout + self._connect_timeout = connect_timeout + self._max_pool_connections = max_pool_connections + self._session = session + self._client: Any = None + self._lock = threading.Lock() + + def client(self) -> Any: # noqa: ANN401 + """Returns the shared bedrock-runtime client, building it on first use. + + Raises: + GenkitError: FAILED_PRECONDITION when no region resolves. Matching + the Go plugin, there is deliberately no default region: a + silent ``us-east-1`` fallback sends traffic (and data) to a + region the user never chose. + """ + with self._lock: + if self._client is None: + self._client = self._build_client() + return self._client + + async def ensure_client(self) -> None: + """Builds the client off-loop so init fails fast on config errors.""" + await asyncio.to_thread(self.client) + + async def converse(self, **kwargs: Any) -> dict[str, Any]: # noqa: ANN401 + """Calls the Converse API on a worker thread. + + Args: + kwargs: Keyword arguments passed verbatim to ``converse``. + + Returns: + The raw Converse response dict. + """ + return await asyncio.to_thread(self._converse_sync, kwargs) + + def _converse_sync(self, kwargs: dict[str, Any]) -> dict[str, Any]: # noqa: ANN401 + return self.client().converse(**kwargs) + + def _build_client(self) -> Any: # noqa: ANN401 + import boto3.session + from botocore.config import Config + + session = self._session or boto3.session.Session() + # botocore only began reading AWS_REGION in 1.41, below this package's + # floor, so resolve it here. A caller-supplied session states its own + # region first; otherwise env wins over ~/.aws/config, as in the SDKs. + env_region = os.environ.get('AWS_REGION') + if self._session is not None: + region = self._region or session.region_name or env_region + else: + region = self._region or env_region or session.region_name + if not region: + raise GenkitError(message=NO_REGION_MESSAGE, status='FAILED_PRECONDITION') + + return session.client( + 'bedrock-runtime', + region_name=region, + config=Config( + retries={'max_attempts': self._max_retries, 'mode': 'standard'}, + read_timeout=self._read_timeout, + connect_timeout=self._connect_timeout, + max_pool_connections=self._max_pool_connections, + ), + ) diff --git a/py/packages/genkit-amazon-bedrock/tests/converters_test.py b/py/packages/genkit-amazon-bedrock/tests/converters_test.py new file mode 100644 index 0000000000..55cea95ca6 --- /dev/null +++ b/py/packages/genkit-amazon-bedrock/tests/converters_test.py @@ -0,0 +1,845 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the Converse request/response converters. + +The expectations mirror the Go plugin's ``generate_test.go`` matrix — they +encode Bedrock wire-format truths, not incidental structure. +""" + +import base64 + +import pytest +from genkit_amazon_bedrock.config import BedrockConfig +from genkit_amazon_bedrock.converters import ( + REASONING_SIGNATURE_METADATA_KEY, + REDACTED_CONTENT_METADATA_KEY, + build_converse_request, + build_inference_config, + cache_point_part, + content_blocks_to_parts, + map_finish_reason, + normalize_config, + to_bedrock_role, + to_bedrock_tool, + to_model_response, + usage_from_response, +) + +from genkit import ( + FinishReason, + Message, + ModelConfig, + ModelRequest, + Part, + ReasoningPart, + Role, + TextPart, + ToolDefinition, +) +from genkit.plugin_api import GenkitError + +PNG_BYTES = b'\x89PNG\r\n\x1a\nfakeimagedata' +PNG_B64 = base64.b64encode(PNG_BYTES).decode() + + +def user_text_request(text: str = 'hello', **kwargs) -> ModelRequest: + return ModelRequest( + messages=[Message(role=Role.USER, content=[Part(root=TextPart(text=text))])], + **kwargs, + ) + + +# --- Stop reasons --------------------------------------------------------- + + +@pytest.mark.parametrize( + 'stop_reason,expected', + [ + ('end_turn', FinishReason.STOP), + ('stop_sequence', FinishReason.STOP), + ('tool_use', FinishReason.STOP), + ('max_tokens', FinishReason.LENGTH), + ('model_context_window_exceeded', FinishReason.LENGTH), + ('content_filtered', FinishReason.BLOCKED), + ('guardrail_intervened', FinishReason.BLOCKED), + ('malformed_model_output', FinishReason.OTHER), + ('malformed_tool_use', FinishReason.OTHER), + ('some_future_reason', FinishReason.OTHER), + ('', FinishReason.OTHER), + (None, FinishReason.OTHER), + ], +) +def test_map_finish_reason(stop_reason, expected) -> None: + assert map_finish_reason(stop_reason) == expected + + +# --- Roles ---------------------------------------------------------------- + + +def test_roles_map_to_converse_vocabulary() -> None: + assert to_bedrock_role(Role.USER) == 'user' + assert to_bedrock_role(Role.TOOL) == 'user' + assert to_bedrock_role(Role.MODEL) == 'assistant' + + +def test_unsupported_role_raises() -> None: + with pytest.raises(GenkitError): + to_bedrock_role('reviewer') + + +def test_unsupported_role_errors_even_for_empty_message() -> None: + # The role is validated before parts are converted, like Go. + request = ModelRequest(messages=[Message(role='reviewer', content=[])]) + with pytest.raises(GenkitError, match='unsupported role'): + build_converse_request('amazon.nova-lite-v1:0', request) + + +# --- Config normalization ------------------------------------------------- + + +def test_normalize_config_none_and_passthrough() -> None: + assert normalize_config(None) is None + config = BedrockConfig(tool_choice='auto') + assert normalize_config(config) is config + + +def test_normalize_config_from_model_config() -> None: + config = normalize_config(ModelConfig(temperature=0.5, max_output_tokens=100, top_p=0.9)) + assert config is not None + assert config.temperature == 0.5 + assert config.max_output_tokens == 100 + assert config.top_p == 0.9 + + +@pytest.mark.parametrize( + 'raw,field,expected', + [ + ({'maxOutputTokens': 50}, 'max_output_tokens', 50), + ({'max_tokens': 60}, 'max_tokens', 60), + ({'maxTokens': 70}, 'max_tokens', 70), + ], +) +def test_normalize_config_from_dict_with_legacy_keys(raw, field, expected) -> None: + config = normalize_config(raw) + assert config is not None + assert getattr(config, field) == expected + + +def test_normalize_config_rejects_unsupported_type() -> None: + with pytest.raises(GenkitError): + normalize_config(42) + + +def test_build_inference_config_empty_is_none() -> None: + assert build_inference_config(None) is None + assert build_inference_config(BedrockConfig()) is None + assert build_inference_config(BedrockConfig(tool_choice='auto')) is None + + +def test_build_inference_config_fields() -> None: + config = BedrockConfig(max_tokens=256, temperature=0.7, top_p=0.9, stop_sequences=['END']) + assert build_inference_config(config) == { + 'maxTokens': 256, + 'temperature': 0.7, + 'topP': 0.9, + 'stopSequences': ['END'], + } + + +def test_bedrock_max_tokens_wins_over_common_field() -> None: + inference_config = build_inference_config(BedrockConfig(max_tokens=100, max_output_tokens=999)) + assert inference_config is not None + assert inference_config['maxTokens'] == 100 + + +def test_explicit_zero_temperature_is_sent() -> None: + # None means unset; an explicit 0.0 is a real setting and must be sent. + inference_config = build_inference_config(BedrockConfig(temperature=0.0)) + assert inference_config == {'temperature': 0.0} + + +def test_top_k_and_version_are_accepted_but_ignored() -> None: + # Converse has no first-class topK or version; Go drops them silently. + request = user_text_request(config=BedrockConfig(top_k=40, version='v9')) + kwargs = build_converse_request('amazon.nova-lite-v1:0', request) + assert 'inferenceConfig' not in kwargs + assert 'additionalModelRequestFields' not in kwargs + + +# --- maxTokens -------------------------------------------------------------- + + +def test_configured_max_tokens_is_sent() -> None: + request = user_text_request(config=BedrockConfig(max_tokens=32)) + kwargs = build_converse_request('anthropic.claude-3-haiku-20240307-v1:0', request) + assert kwargs['inferenceConfig'] == {'maxTokens': 32} + + +@pytest.mark.parametrize('model_id', ['us.anthropic.claude-sonnet-4-5-20250929-v1:0', 'amazon.nova-lite-v1:0']) +def test_no_max_tokens_is_injected_for_any_model(model_id) -> None: + # Verified live: Converse accepts Claude requests without maxTokens and + # applies a service default, so nothing is guessed on the caller's behalf. + kwargs = build_converse_request(model_id, user_text_request()) + assert 'inferenceConfig' not in kwargs + + +# --- Request assembly ------------------------------------------------------- + + +def test_model_id_sent_verbatim_with_inference_profile_prefix() -> None: + kwargs = build_converse_request('us.amazon.nova-lite-v1:0', user_text_request()) + assert kwargs['modelId'] == 'us.amazon.nova-lite-v1:0' + + +def test_simple_text_round_trip_shape() -> None: + kwargs = build_converse_request('amazon.nova-lite-v1:0', user_text_request('hi')) + assert kwargs['messages'] == [{'role': 'user', 'content': [{'text': 'hi'}]}] + assert 'system' not in kwargs + assert 'toolConfig' not in kwargs + + +def test_system_message_becomes_top_level_system() -> None: + request = ModelRequest( + messages=[ + Message(role=Role.SYSTEM, content=[Part(root=TextPart(text='be terse'))]), + Message(role=Role.USER, content=[Part(root=TextPart(text='hi'))]), + ] + ) + kwargs = build_converse_request('amazon.nova-lite-v1:0', request) + assert kwargs['system'] == [{'text': 'be terse'}] + assert kwargs['messages'] == [{'role': 'user', 'content': [{'text': 'hi'}]}] + + +def test_empty_system_text_is_dropped() -> None: + # Bedrock rejects empty system text, so a blank rendered prompt is dropped + # rather than sent; regular message text has no such floor. + request = ModelRequest( + messages=[ + Message(role=Role.SYSTEM, content=[Part(root=TextPart(text=''))]), + Message(role=Role.USER, content=[Part(root=TextPart(text=''))]), + ] + ) + kwargs = build_converse_request('amazon.nova-lite-v1:0', request) + assert 'system' not in kwargs + assert kwargs['messages'] == [{'role': 'user', 'content': [{'text': ''}]}] + + +def test_cache_point_in_system_and_messages() -> None: + request = ModelRequest( + messages=[ + Message(role=Role.SYSTEM, content=[Part(root=TextPart(text='rules')), cache_point_part()]), + Message(role=Role.USER, content=[Part(root=TextPart(text='hi')), cache_point_part()]), + ] + ) + kwargs = build_converse_request('amazon.nova-lite-v1:0', request) + assert kwargs['system'] == [{'text': 'rules'}, {'cachePoint': {'type': 'default'}}] + assert kwargs['messages'][0]['content'] == [{'text': 'hi'}, {'cachePoint': {'type': 'default'}}] + + +def test_multi_turn_roles() -> None: + request = ModelRequest( + messages=[ + Message(role=Role.USER, content=[Part(root=TextPart(text='q'))]), + Message(role=Role.MODEL, content=[Part(root=TextPart(text='a'))]), + Message(role=Role.USER, content=[Part(root=TextPart(text='q2'))]), + ] + ) + kwargs = build_converse_request('amazon.nova-lite-v1:0', request) + assert [m['role'] for m in kwargs['messages']] == ['user', 'assistant', 'user'] + + +def test_empty_messages_are_dropped() -> None: + request = ModelRequest( + messages=[ + Message(role=Role.USER, content=[Part(root=TextPart(text='hi'))]), + Message(role=Role.MODEL, content=[]), + ] + ) + kwargs = build_converse_request('amazon.nova-lite-v1:0', request) + assert len(kwargs['messages']) == 1 + + +def test_tool_role_message_becomes_user_tool_result() -> None: + request = ModelRequest( + messages=[ + Message( + role=Role.TOOL, + content=[ + Part.model_validate({'toolResponse': {'ref': 'call-1', 'name': 'weather', 'output': {'temp': 21}}}) + ], + ), + ] + ) + kwargs = build_converse_request('amazon.nova-lite-v1:0', request) + message = kwargs['messages'][0] + assert message['role'] == 'user' + tool_result = message['content'][0]['toolResult'] + assert tool_result['toolUseId'] == 'call-1' + assert tool_result['status'] == 'success' + # Non-string outputs ride as a JSON string in a text content block. + assert tool_result['content'] == [{'text': '{"temp": 21}'}] + + +def test_string_tool_output_is_verbatim() -> None: + request = ModelRequest( + messages=[ + Message( + role=Role.TOOL, + content=[ + Part.model_validate({'toolResponse': {'ref': 'call-2', 'name': 'weather', 'output': 'sunny'}}) + ], + ), + ] + ) + kwargs = build_converse_request('amazon.nova-lite-v1:0', request) + tool_result = kwargs['messages'][0]['content'][0]['toolResult'] + assert tool_result['content'] == [{'text': 'sunny'}] + assert tool_result['toolUseId'] == 'call-2' + + +@pytest.mark.parametrize( + 'part', + [ + {'toolRequest': {'name': 'weather', 'input': {}}}, + {'toolResponse': {'name': 'weather', 'output': 'sunny'}}, + ], + ids=['tool_request', 'tool_response'], +) +def test_tool_part_without_ref_errors(part: dict[str, object]) -> None: + # Bedrock's toolUseId has a one-character floor, so '' cannot be sent. + request = ModelRequest(messages=[Message(role=Role.TOOL, content=[Part.model_validate(part)])]) + with pytest.raises(GenkitError, match='requires a ref to send as toolUseId') as excinfo: + build_converse_request('amazon.nova-lite-v1:0', request) + assert excinfo.value.status == 'INVALID_ARGUMENT' + + +def test_tool_request_part_becomes_tool_use_block() -> None: + request = ModelRequest( + messages=[ + Message( + role=Role.MODEL, + content=[ + Part.model_validate({ + 'toolRequest': {'ref': 'call-9', 'name': 'weather', 'input': {'city': 'Lagos'}} + }) + ], + ), + ] + ) + kwargs = build_converse_request('amazon.nova-lite-v1:0', request) + assert kwargs['messages'][0]['content'][0]['toolUse'] == { + 'toolUseId': 'call-9', + 'name': 'weather', + 'input': {'city': 'Lagos'}, + } + + +# --- Media ------------------------------------------------------------------ + + +def test_image_data_url_decodes_to_raw_bytes() -> None: + part = Part.model_validate({'media': {'url': f'data:image/png;base64,{PNG_B64}'}}) + request = ModelRequest(messages=[Message(role=Role.USER, content=[part])]) + kwargs = build_converse_request('amazon.nova-lite-v1:0', request) + image = kwargs['messages'][0]['content'][0]['image'] + assert image['format'] == 'png' + assert image['source']['bytes'] == PNG_BYTES + + +def test_explicit_content_type_beats_data_url_header() -> None: + part = Part.model_validate({'media': {'url': f'data:image/png;base64,{PNG_B64}', 'contentType': 'image/jpeg'}}) + request = ModelRequest(messages=[Message(role=Role.USER, content=[part])]) + kwargs = build_converse_request('amazon.nova-lite-v1:0', request) + assert kwargs['messages'][0]['content'][0]['image']['format'] == 'jpeg' + + +def test_jpg_alias_normalizes_to_jpeg() -> None: + part = Part.model_validate({'media': {'url': PNG_B64, 'contentType': 'image/jpg'}}) + request = ModelRequest(messages=[Message(role=Role.USER, content=[part])]) + kwargs = build_converse_request('amazon.nova-lite-v1:0', request) + assert kwargs['messages'][0]['content'][0]['image']['format'] == 'jpeg' + + +def test_document_mime_maps_to_document_block() -> None: + part = Part.model_validate({'media': {'url': PNG_B64, 'contentType': 'application/pdf'}}) + request = ModelRequest(messages=[Message(role=Role.USER, content=[part])]) + kwargs = build_converse_request('amazon.nova-lite-v1:0', request) + document = kwargs['messages'][0]['content'][0]['document'] + assert document['format'] == 'pdf' + assert document['name'] == 'document' + assert document['source']['bytes'] == PNG_BYTES + + +def test_html_is_document_not_image() -> None: + part = Part.model_validate({'media': {'url': PNG_B64, 'contentType': 'text/html'}}) + request = ModelRequest(messages=[Message(role=Role.USER, content=[part])]) + kwargs = build_converse_request('amazon.nova-lite-v1:0', request) + assert 'document' in kwargs['messages'][0]['content'][0] + + +@pytest.mark.parametrize( + 'mime,block_kind,expected_format', + [ + ('image/gif', 'image', 'gif'), + ('image/webp', 'image', 'webp'), + ('text/csv', 'document', 'csv'), + ('text/markdown', 'document', 'md'), + ('text/plain', 'document', 'txt'), + ('application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'document', 'docx'), + ('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'document', 'xlsx'), + ], +) +def test_media_mime_format_matrix(mime, block_kind, expected_format) -> None: + part = Part.model_validate({'media': {'url': PNG_B64, 'contentType': mime}}) + request = ModelRequest(messages=[Message(role=Role.USER, content=[part])]) + kwargs = build_converse_request('amazon.nova-lite-v1:0', request) + block = kwargs['messages'][0]['content'][0] + assert block[block_kind]['format'] == expected_format + + +@pytest.mark.parametrize( + 'url,match', + [ + ('https://example.com/cat.png', 'remote URLs are not supported'), + ('data:image/png,rawdata', 'must be base64-encoded'), + (' ', 'empty data'), + ('!!!not-base64!!!', 'decode base64 media'), + ], +) +def test_media_payload_validation_errors(url, match) -> None: + part = Part.model_validate({'media': {'url': url, 'contentType': 'image/png'}}) + request = ModelRequest(messages=[Message(role=Role.USER, content=[part])]) + with pytest.raises(GenkitError, match=match): + build_converse_request('amazon.nova-lite-v1:0', request) + + +def test_unsupported_mime_type_errors() -> None: + part = Part.model_validate({'media': {'url': PNG_B64, 'contentType': 'video/mp4'}}) + request = ModelRequest(messages=[Message(role=Role.USER, content=[part])]) + with pytest.raises(GenkitError, match='unsupported media MIME type'): + build_converse_request('amazon.nova-lite-v1:0', request) + + +def test_media_without_content_type_errors() -> None: + part = Part.model_validate({'media': {'url': PNG_B64}}) + request = ModelRequest(messages=[Message(role=Role.USER, content=[part])]) + with pytest.raises(GenkitError, match='no content type'): + build_converse_request('amazon.nova-lite-v1:0', request) + + +# --- Tools and tool choice --------------------------------------------------- + + +WEATHER_TOOL = ToolDefinition( + name='weather', + description='Get the weather', + input_schema={'type': 'object', 'properties': {'city': {'type': 'string'}}}, +) + + +def test_tools_become_tool_specs() -> None: + request = user_text_request(tools=[WEATHER_TOOL]) + kwargs = build_converse_request('amazon.nova-lite-v1:0', request) + tool_spec = kwargs['toolConfig']['tools'][0]['toolSpec'] + assert tool_spec['name'] == 'weather' + assert tool_spec['description'] == 'Get the weather' + assert tool_spec['inputSchema']['json']['properties'] == {'city': {'type': 'string'}} + # No toolChoice unless requested; Bedrock defaults to auto. + assert 'toolChoice' not in kwargs['toolConfig'] + + +def test_tool_schema_defaults_injected() -> None: + tool = ToolDefinition(name='noop', description='') + schema = to_bedrock_tool(tool)['toolSpec']['inputSchema']['json'] + assert schema['type'] == 'object' + assert schema['properties'] == {} + assert schema['$schema'] == 'http://json-schema.org/draft-07/schema#' + + +def test_empty_tool_description_is_omitted() -> None: + # Bedrock rejects an empty description, and a tool declared without a + # docstring reaches the plugin with description ''. + tool_spec = to_bedrock_tool(ToolDefinition(name='noop', description=''))['toolSpec'] + assert 'description' not in tool_spec + assert tool_spec['name'] == 'noop' + + +def test_tool_without_name_errors() -> None: + with pytest.raises(GenkitError, match='tool name required'): + to_bedrock_tool(ToolDefinition(name='', description='')) + + +@pytest.mark.parametrize( + 'tool_choice,expected', + [ + ('auto', {'auto': {}}), + ('required', {'any': {}}), + ('any', {'any': {}}), + ('weather', {'tool': {'name': 'weather'}}), + ], +) +def test_tool_choice_mapping(tool_choice, expected) -> None: + request = user_text_request(tools=[WEATHER_TOOL], config=BedrockConfig(tool_choice=tool_choice)) + kwargs = build_converse_request('amazon.nova-lite-v1:0', request) + assert kwargs['toolConfig']['toolChoice'] == expected + + +def test_tool_choice_none_omits_tool_config_entirely() -> None: + request = user_text_request(tools=[WEATHER_TOOL], config=BedrockConfig(tool_choice='none')) + kwargs = build_converse_request('amazon.nova-lite-v1:0', request) + assert 'toolConfig' not in kwargs + + +def test_request_tool_choice_used_when_config_silent() -> None: + request = user_text_request(tools=[WEATHER_TOOL], tool_choice='required') + kwargs = build_converse_request('amazon.nova-lite-v1:0', request) + assert kwargs['toolConfig']['toolChoice'] == {'any': {}} + + +def test_unknown_named_tool_choice_errors() -> None: + request = user_text_request(tools=[WEATHER_TOOL], config=BedrockConfig(tool_choice='no-such-tool')) + with pytest.raises(GenkitError, match='does not match any declared tool'): + build_converse_request('amazon.nova-lite-v1:0', request) + + +def test_tool_choice_without_tools_is_ignored() -> None: + request = user_text_request(config=BedrockConfig(tool_choice='weather')) + kwargs = build_converse_request('amazon.nova-lite-v1:0', request) + assert 'toolConfig' not in kwargs + + +def test_trailing_assistant_message_dropped_when_tools_present() -> None: + request = ModelRequest( + messages=[ + Message(role=Role.USER, content=[Part(root=TextPart(text='q'))]), + Message(role=Role.MODEL, content=[Part(root=TextPart(text='thinking...'))]), + ], + tools=[WEATHER_TOOL], + ) + kwargs = build_converse_request('amazon.nova-lite-v1:0', request) + assert [m['role'] for m in kwargs['messages']] == ['user'] + + +def test_trailing_assistant_message_kept_without_tools() -> None: + request = ModelRequest( + messages=[ + Message(role=Role.USER, content=[Part(root=TextPart(text='q'))]), + Message(role=Role.MODEL, content=[Part(root=TextPart(text='a'))]), + ] + ) + kwargs = build_converse_request('amazon.nova-lite-v1:0', request) + assert [m['role'] for m in kwargs['messages']] == ['user', 'assistant'] + + +def test_trailing_assistant_dropped_even_under_tool_choice_none() -> None: + request = ModelRequest( + messages=[ + Message(role=Role.USER, content=[Part(root=TextPart(text='q'))]), + Message(role=Role.MODEL, content=[Part(root=TextPart(text='a'))]), + ], + tools=[WEATHER_TOOL], + config=BedrockConfig(tool_choice='none'), + ) + kwargs = build_converse_request('amazon.nova-lite-v1:0', request) + assert [m['role'] for m in kwargs['messages']] == ['user'] + + +def test_additional_model_request_fields_forwarded_verbatim() -> None: + thinking = {'thinking': {'type': 'enabled', 'budget_tokens': 2048}} + request = user_text_request(config=BedrockConfig(additional_model_request_fields=thinking)) + kwargs = build_converse_request('amazon.nova-lite-v1:0', request) + assert kwargs['additionalModelRequestFields'] == thinking + + +# --- Reasoning round-trip (request side) ------------------------------------- + + +def test_bedrock_reasoning_part_round_trips() -> None: + # The signature is a string on the Converse wire; it must replay verbatim. + part = Part( + root=ReasoningPart( + reasoning='step by step', + metadata={REASONING_SIGNATURE_METADATA_KEY: 'sig-abc123=='}, + ) + ) + request = ModelRequest( + messages=[ + Message(role=Role.MODEL, content=[part]), + Message(role=Role.USER, content=[Part(root=TextPart(text='go on'))]), + ] + ) + kwargs = build_converse_request('amazon.nova-lite-v1:0', request) + assert kwargs['messages'][0]['content'] == [ + {'reasoningContent': {'reasoningText': {'text': 'step by step', 'signature': 'sig-abc123=='}}} + ] + + +def test_bytes_signature_form_is_tolerated() -> None: + part = Part( + root=ReasoningPart( + reasoning='step by step', + metadata={REASONING_SIGNATURE_METADATA_KEY: b'sig-abc123=='}, + ) + ) + request = ModelRequest(messages=[Message(role=Role.MODEL, content=[part])]) + kwargs = build_converse_request('amazon.nova-lite-v1:0', request) + block = kwargs['messages'][0]['content'][0]['reasoningContent']['reasoningText'] + assert block['signature'] == 'sig-abc123==' + + +def test_redacted_content_emitted_before_signed_text() -> None: + part = Part( + root=ReasoningPart( + reasoning='visible part', + metadata={ + REASONING_SIGNATURE_METADATA_KEY: 'sig', + REDACTED_CONTENT_METADATA_KEY: base64.b64encode(b'redacted-blob').decode(), + }, + ) + ) + request = ModelRequest(messages=[Message(role=Role.MODEL, content=[part])]) + kwargs = build_converse_request('amazon.nova-lite-v1:0', request) + blocks = kwargs['messages'][0]['content'] + assert blocks[0] == {'reasoningContent': {'redactedContent': b'redacted-blob'}} + assert blocks[1]['reasoningContent']['reasoningText']['text'] == 'visible part' + + +def test_redacted_only_reasoning_part_still_replays() -> None: + # Redacted-only parts have reasoning == '' and must not be dropped. + part = Part( + root=ReasoningPart( + reasoning='', + metadata={REDACTED_CONTENT_METADATA_KEY: base64.b64encode(b'blob').decode()}, + ) + ) + request = ModelRequest(messages=[Message(role=Role.MODEL, content=[part])]) + kwargs = build_converse_request('amazon.nova-lite-v1:0', request) + assert kwargs['messages'][0]['content'] == [{'reasoningContent': {'redactedContent': b'blob'}}] + + +def test_generic_reasoning_part_is_not_replayed() -> None: + request = ModelRequest( + messages=[ + Message(role=Role.USER, content=[Part(root=TextPart(text='q'))]), + Message(role=Role.MODEL, content=[Part(root=ReasoningPart(reasoning='foreign thoughts'))]), + ] + ) + kwargs = build_converse_request('amazon.nova-lite-v1:0', request) + # The reasoning-only assistant message converts to zero blocks and drops. + assert [m['role'] for m in kwargs['messages']] == ['user'] + + +# --- Response conversion ------------------------------------------------------ + + +def converse_response(**overrides) -> dict: + response = { + 'output': {'message': {'role': 'assistant', 'content': [{'text': 'hello'}]}}, + 'stopReason': 'end_turn', + 'usage': {'inputTokens': 10, 'outputTokens': 5, 'totalTokens': 15}, + } + response.update(overrides) + return response + + +def test_text_response_round_trip() -> None: + request = user_text_request() + response = to_model_response(converse_response(), request) + assert response.message is not None + assert response.message.role == Role.MODEL + assert response.message.content[0].root.text == 'hello' + assert response.finish_reason == FinishReason.STOP + assert response.usage is not None + assert response.usage.input_tokens == 10 + assert response.usage.output_tokens == 5 + assert response.usage.total_tokens == 15 + assert response.request is request + + +def test_tool_use_block_becomes_tool_request_part() -> None: + blocks = [{'toolUse': {'toolUseId': 'call-1', 'name': 'weather', 'input': {'city': 'Lagos'}}}] + parts = content_blocks_to_parts(blocks) + tool_request = parts[0].root.tool_request + assert tool_request is not None + assert tool_request.ref == 'call-1' + assert tool_request.name == 'weather' + assert tool_request.input == {'city': 'Lagos'} + + +def test_tool_use_with_missing_input_gets_empty_object() -> None: + parts = content_blocks_to_parts([{'toolUse': {'toolUseId': 'x', 'name': 'noop'}}]) + tool_request = parts[0].root.tool_request + assert tool_request is not None + assert tool_request.input == {} + + +def test_tool_input_coerced_toward_schema() -> None: + tool = ToolDefinition( + name='calc', + description='', + input_schema={ + 'type': 'object', + 'properties': { + 'count': {'type': 'integer'}, + 'ratio': {'type': 'number'}, + 'enabled': {'type': 'boolean'}, + 'note': {'type': 'string'}, + }, + }, + ) + blocks = [ + { + 'toolUse': { + 'toolUseId': 'c1', + 'name': 'calc', + 'input': {'count': '7', 'ratio': '0.5', 'enabled': 'true', 'note': 'hi', 'extra': '1'}, + } + } + ] + parts = content_blocks_to_parts(blocks, [tool]) + tool_request = parts[0].root.tool_request + assert tool_request is not None + assert tool_request.input == {'count': 7, 'ratio': 0.5, 'enabled': True, 'note': 'hi', 'extra': '1'} + + +@pytest.mark.parametrize('value,expected', [(7, '7'), (7.5, '7.5'), (True, True)], ids=['int', 'float', 'bool']) +def test_number_coerced_to_string_schema(value: object, expected: object) -> None: + # Go coerces a wire number to its string form; without it, tool dispatch + # fails pydantic validation. Booleans are left alone, as in Go. + tool = ToolDefinition( + name='calc', + description='', + input_schema={'type': 'object', 'properties': {'note': {'type': 'string'}}}, + ) + blocks = [{'toolUse': {'toolUseId': 'c1', 'name': 'calc', 'input': {'note': value}}}] + parts = content_blocks_to_parts(blocks, [tool]) + tool_request = parts[0].root.tool_request + assert tool_request is not None + assert tool_request.input == {'note': expected} + + +def test_tool_input_float_truncates_for_integer_schema() -> None: + tool = ToolDefinition( + name='calc', description='', input_schema={'type': 'object', 'properties': {'n': {'type': 'integer'}}} + ) + parts = content_blocks_to_parts([{'toolUse': {'toolUseId': 'c', 'name': 'calc', 'input': {'n': 7.9}}}], [tool]) + tool_request = parts[0].root.tool_request + assert tool_request is not None + assert tool_request.input == {'n': 7} + + +def test_reasoning_text_block_becomes_reasoning_part_with_both_keys() -> None: + blocks = [{'reasoningContent': {'reasoningText': {'text': 'because', 'signature': 'sig'}}}] + parts = content_blocks_to_parts(blocks) + root = parts[0].root + assert root.reasoning == 'because' + assert root.metadata is not None + assert root.metadata['signature'] == 'sig' + assert root.metadata[REASONING_SIGNATURE_METADATA_KEY] == 'sig' + + +def test_reasoning_text_bare_string_shape_is_handled() -> None: + parts = content_blocks_to_parts([{'reasoningContent': {'reasoningText': 'raw thought'}}]) + assert parts[0].root.reasoning == 'raw thought' + + +def test_redacted_content_block_becomes_reasoning_part() -> None: + parts = content_blocks_to_parts([{'reasoningContent': {'redactedContent': b'blob'}}]) + root = parts[0].root + assert root.reasoning == '' + assert root.metadata is not None + # Stored as a base64 string so the part survives JSON serialization. + assert root.metadata[REDACTED_CONTENT_METADATA_KEY] == base64.b64encode(b'blob').decode() + + +def test_reasoning_survives_response_to_request_round_trip() -> None: + # The full circle: wire response blocks -> Genkit parts -> wire request + # blocks, byte-identical, including non-UTF8 redacted content. + redacted_blob = b'\x89\xff\x00binary' + response_blocks = [ + {'reasoningContent': {'reasoningText': {'text': 'because', 'signature': 'sig-abc123=='}}}, + {'reasoningContent': {'redactedContent': redacted_blob}}, + ] + parts = content_blocks_to_parts(response_blocks) + request = ModelRequest(messages=[Message(role=Role.MODEL, content=parts)]) + kwargs = build_converse_request('amazon.nova-lite-v1:0', request) + replayed = kwargs['messages'][0]['content'] + assert replayed[0] == {'reasoningContent': {'reasoningText': {'text': 'because', 'signature': 'sig-abc123=='}}} + assert replayed[1] == {'reasoningContent': {'redactedContent': redacted_blob}} + + +def test_reasoning_parts_serialize_to_json() -> None: + # Tracing serializes action output with model_dump_json; raw bytes in + # metadata would crash it for non-UTF8 redacted blobs. + parts = content_blocks_to_parts([ + {'reasoningContent': {'reasoningText': {'text': 'because', 'signature': 'sig'}}}, + {'reasoningContent': {'redactedContent': b'\x89\xff\x00binary'}}, + ]) + message = Message(role=Role.MODEL, content=parts) + assert message.model_dump_json() + + +def test_empty_reasoning_block_is_skipped() -> None: + assert content_blocks_to_parts([{'reasoningContent': {'reasoningText': {}}}]) == [] + + +def test_unknown_reasoning_variant_errors() -> None: + with pytest.raises(GenkitError, match='unhandled reasoning content variant'): + content_blocks_to_parts([{'reasoningContent': {'futureThing': 1}}]) + + +def test_unknown_response_block_errors() -> None: + with pytest.raises(GenkitError, match='unhandled response content variant'): + content_blocks_to_parts([{'video': {'format': 'mp4'}}]) + + +def test_empty_response_content_yields_placeholder_text_part() -> None: + response = converse_response( + output={'message': {'role': 'assistant', 'content': []}}, + stopReason='guardrail_intervened', + ) + model_response = to_model_response(response, user_text_request()) + assert model_response.message is not None + assert model_response.message.content[0].root.text == '' + assert model_response.finish_reason == FinishReason.BLOCKED + + +def test_missing_output_tolerated() -> None: + response = converse_response(output={}) + model_response = to_model_response(response, user_text_request()) + assert model_response.message is not None + assert model_response.message.content[0].root.text == '' + + +def test_usage_maps_cache_read_tokens_only() -> None: + usage = usage_from_response({ + 'inputTokens': 4, + 'outputTokens': 6, + 'totalTokens': 110, + 'cacheReadInputTokens': 100, + 'cacheWriteInputTokens': 50, + }) + assert usage is not None + assert usage.input_tokens == 4 + assert usage.output_tokens == 6 + # Totals are trusted verbatim from AWS, never recomputed. + assert usage.total_tokens == 110 + assert usage.cached_content_tokens == 100 + + +def test_usage_none_when_absent() -> None: + assert usage_from_response(None) is None diff --git a/py/packages/genkit-amazon-bedrock/tests/live_test.py b/py/packages/genkit-amazon-bedrock/tests/live_test.py new file mode 100644 index 0000000000..2b5f6524ef --- /dev/null +++ b/py/packages/genkit-amazon-bedrock/tests/live_test.py @@ -0,0 +1,194 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Live Bedrock tests, mirroring the Go plugin's live matrix. + +Opt-in: set ``BEDROCK_LIVE_TESTS=1`` plus working AWS credentials and a +region (``AWS_REGION`` or ``~/.aws/config``). These call real models and +incur cost. The Anthropic models additionally need the account's one-time +use-case agreement (Bedrock console -> Model access). +""" + +import os + +import pytest +from genkit_amazon_bedrock.config import BedrockConfig +from genkit_amazon_bedrock.converters import ( + REASONING_SIGNATURE_METADATA_KEY, +) +from genkit_amazon_bedrock.models import BedrockModel +from genkit_amazon_bedrock.transport import BedrockTransport + +from genkit import FinishReason, Message, ModelRequest, Part, Role, TextPart, ToolDefinition + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.skipif( + not os.environ.get('BEDROCK_LIVE_TESTS'), + reason='BEDROCK_LIVE_TESTS not set; live Bedrock tests are opt-in', + ), +] + +CLAUDE = 'us.anthropic.claude-sonnet-4-5-20250929-v1:0' +NOVA = 'us.amazon.nova-lite-v1:0' +DEEPSEEK = 'us.deepseek.r1-v1:0' + + +def make_model(model_id: str) -> BedrockModel: + transport = BedrockTransport( + region=os.environ.get('AWS_REGION'), + max_retries=3, + read_timeout=300.0, + connect_timeout=60.0, + max_pool_connections=10, + ) + return BedrockModel(model_id=model_id, transport=transport) + + +def text_request(text: str, **kwargs) -> ModelRequest: + return ModelRequest( + messages=[Message(role=Role.USER, content=[Part(root=TextPart(text=text))])], + **kwargs, + ) + + +def undocumented_weather_tool() -> ToolDefinition: + # No description on purpose: Bedrock rejects an empty one, and a Genkit + # tool declared without a docstring arrives that way. + return ToolDefinition( + name='get_weather', + description='', + input_schema={ + 'type': 'object', + 'properties': {'city': {'type': 'string'}}, + 'required': ['city'], + }, + ) + + +async def test_nova_sync() -> None: + response = await make_model(NOVA).generate(text_request("Reply with the single word 'pong'.")) + assert response.finish_reason == FinishReason.STOP + assert response.message is not None + assert response.message.content[0].root.text + assert response.usage is not None + assert response.usage.input_tokens is not None and response.usage.input_tokens > 0 + + +async def test_undocumented_tool_round_trip() -> None: + weather = undocumented_weather_tool() + request = ModelRequest( + messages=[Message(role=Role.USER, content=[Part(root=TextPart(text='What is the weather in Lagos?'))])], + tools=[weather], + config=BedrockConfig(tool_choice='get_weather'), + ) + response = await make_model(NOVA).generate(request) + + assert response.message is not None + tool_requests = [part.root.tool_request for part in response.message.content if part.root.tool_request is not None] + assert tool_requests, 'expected the model to call the tool' + assert tool_requests[0].name == 'get_weather' + assert tool_requests[0].ref + + # Feeding the result back must also be accepted. + follow_up = ModelRequest( + messages=[ + *request.messages, + response.message, + Message( + role=Role.TOOL, + content=[ + Part.model_validate({ + 'toolResponse': { + 'ref': tool_requests[0].ref, + 'name': 'get_weather', + 'output': {'celsius': 31}, + } + }) + ], + ), + ], + tools=[weather], + ) + assert (await make_model(NOVA).generate(follow_up)).message is not None + + +async def test_claude_sync_without_config() -> None: + # No config on purpose: Converse accepts Claude requests without maxTokens + # and applies a service default, so the plugin injects nothing. + response = await make_model(CLAUDE).generate(text_request("Reply with the single word 'pong'.")) + assert response.finish_reason == FinishReason.STOP + assert response.message is not None + text = response.message.content[0].root.text + assert text is not None and 'pong' in text.lower() + + +async def test_claude_reasoning_signature_round_trip() -> None: + model = make_model(CLAUDE) + # Bedrock requires budget_tokens >= 1024 and maxTokens above it; thinking + # requests reject custom temperature, so none is set. + config = BedrockConfig( + max_tokens=4096, + additional_model_request_fields={'thinking': {'type': 'enabled', 'budget_tokens': 1024}}, + ) + request = text_request('What is 17 * 23? Think it through.', config=config) + response = await model.generate(request) + + assert response.message is not None + reasoning_parts = [ + part.root for part in response.message.content if getattr(part.root, 'reasoning', None) is not None + ] + assert reasoning_parts, 'expected a reasoning part on a thinking-enabled sync call' + assert reasoning_parts[0].metadata is not None + assert reasoning_parts[0].metadata.get(REASONING_SIGNATURE_METADATA_KEY) + + # Replaying the signed reasoning verbatim must be accepted by Bedrock. + follow_up = ModelRequest( + messages=[ + *request.messages, + response.message, + Message(role=Role.USER, content=[Part(root=TextPart(text='Now add 100 to that.'))]), + ], + config=config, + ) + follow_up_response = await model.generate(follow_up) + assert follow_up_response.finish_reason == FinishReason.STOP + + +async def test_deepseek_reasoning_sync_and_round_trip() -> None: + model = make_model(DEEPSEEK) + config = BedrockConfig(max_tokens=2048) + request = text_request('What is 17 * 23? Think it through.', config=config) + response = await model.generate(request) + + assert response.message is not None + reasoning_parts = [ + part.root for part in response.message.content if getattr(part.root, 'reasoning', None) is not None + ] + assert reasoning_parts, 'expected a reasoning part from a reasoning model' + # Signatures are Anthropic-specific, so replay stays gated off here. + metadata = reasoning_parts[0].metadata + assert metadata is None or not metadata.get(REASONING_SIGNATURE_METADATA_KEY) + + follow_up = ModelRequest( + messages=[ + *request.messages, + response.message, + Message(role=Role.USER, content=[Part(root=TextPart(text='Now add 100 to that.'))]), + ], + config=config, + ) + assert (await model.generate(follow_up)).finish_reason == FinishReason.STOP diff --git a/py/packages/genkit-amazon-bedrock/tests/model_info_test.py b/py/packages/genkit-amazon-bedrock/tests/model_info_test.py new file mode 100644 index 0000000000..c263eb0153 --- /dev/null +++ b/py/packages/genkit-amazon-bedrock/tests/model_info_test.py @@ -0,0 +1,129 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the Bedrock model capability registry.""" + +import pytest +from genkit_amazon_bedrock.model_info import ( + INFERENCE_PROFILE_PREFIXES, + MODEL_CAPABILITIES, + get_model_info, + strip_inference_profile_prefix, +) + +from genkit import Stage + + +@pytest.mark.parametrize('prefix', INFERENCE_PROFILE_PREFIXES) +def test_strip_inference_profile_prefix(prefix: str) -> None: + model_id = f'{prefix}anthropic.claude-sonnet-4-5-20250929-v1:0' + assert strip_inference_profile_prefix(model_id) == 'anthropic.claude-sonnet-4-5-20250929-v1:0' + + +def test_strip_leaves_bare_model_id_untouched() -> None: + assert strip_inference_profile_prefix('amazon.nova-lite-v1:0') == 'amazon.nova-lite-v1:0' + + +def test_strip_only_removes_first_matching_prefix() -> None: + assert strip_inference_profile_prefix('us.us.model') == 'us.model' + + +def test_strip_handles_inference_profile_arn() -> None: + arn = 'arn:aws:bedrock:us-east-1:123456789012:inference-profile/us.anthropic.claude-3-5-sonnet-20241022-v2:0' + assert strip_inference_profile_prefix(arn) == 'anthropic.claude-3-5-sonnet-20241022-v2:0' + + +def test_strip_handles_foundation_model_arn() -> None: + arn = 'arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-3-haiku-20240307-v1:0' + assert strip_inference_profile_prefix(arn) == 'anthropic.claude-3-haiku-20240307-v1:0' + + +def test_strip_handles_govcloud_partition_arn() -> None: + arn = ( + 'arn:aws-us-gov:bedrock:us-gov-west-1:123456789012:' + 'inference-profile/us-gov.anthropic.claude-3-5-sonnet-20240620-v1:0' + ) + assert strip_inference_profile_prefix(arn) == 'anthropic.claude-3-5-sonnet-20240620-v1:0' + + +def test_application_inference_profile_arn_falls_back_to_unstable() -> None: + arn = 'arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123opaque' + info = get_model_info(arn) + assert info.stage == Stage.UNSTABLE + assert info.supports is not None + assert info.supports.tools is True + + +def test_us_gov_wins_over_us() -> None: + assert strip_inference_profile_prefix('us-gov.anthropic.claude-3-opus-20240229-v1:0') == ( + 'anthropic.claude-3-opus-20240229-v1:0' + ) + + +def test_known_model_is_stable_with_registry_capabilities() -> None: + info = get_model_info('anthropic.claude-3-5-haiku-20241022-v1:0') + assert info.stage == Stage.STABLE + assert info.supports is not None + assert info.supports.multiturn is True + assert info.supports.system_role is True + assert info.supports.tools is True + assert info.supports.tool_choice is True + assert info.supports.media is False + + +def test_inference_profile_id_resolves_registry_entry() -> None: + info = get_model_info('eu.amazon.nova-micro-v1:0') + assert info.stage == Stage.STABLE + assert info.supports is not None + assert info.supports.media is False + assert info.label == 'eu.amazon.nova-micro-v1:0' + + +def test_unknown_model_defaults_to_unstable_converse_capabilities() -> None: + info = get_model_info('vendor.brand-new-model-v1:0') + assert info.stage == Stage.UNSTABLE + assert info.supports is not None + assert info.supports.multiturn is True + assert info.supports.tools is True + assert info.supports.media is True + + +def test_image_model_supports_media_output_only() -> None: + info = get_model_info('amazon.titan-image-generator-v1', model_type='image') + assert info.stage == Stage.STABLE + assert info.supports is not None + assert info.supports.media is True + assert info.supports.multiturn is False + assert info.supports.tools is False + assert info.supports.system_role is False + + +def test_embedding_model_supports_nothing() -> None: + info = get_model_info('amazon.titan-embed-text-v2:0', model_type='embedding') + assert info.stage == Stage.STABLE + assert info.supports is not None + assert info.supports.media is False + assert info.supports.multiturn is False + assert info.supports.tools is False + + +def test_registry_matches_go_plugin_size() -> None: + assert len(MODEL_CAPABILITIES) == 47 + + +def test_all_registry_keys_are_base_ids() -> None: + for model_id in MODEL_CAPABILITIES: + assert strip_inference_profile_prefix(model_id) == model_id diff --git a/py/packages/genkit-amazon-bedrock/tests/models_test.py b/py/packages/genkit-amazon-bedrock/tests/models_test.py new file mode 100644 index 0000000000..63610d5b6a --- /dev/null +++ b/py/packages/genkit-amazon-bedrock/tests/models_test.py @@ -0,0 +1,157 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the BedrockModel generate orchestration (no AWS involved).""" + +from typing import Any + +import pytest +from botocore.exceptions import ( + BotoCoreError, + ClientError, + EndpointConnectionError, + NoCredentialsError, + ParamValidationError, + ReadTimeoutError, +) +from genkit_amazon_bedrock.models import BedrockModel + +from genkit import FinishReason, Message, ModelRequest, Part, Role, TextPart +from genkit.plugin_api import ActionRunContext, GenkitError + + +class FakeTransport: + """Stands in for BedrockTransport; records the Converse kwargs.""" + + def __init__(self, response: dict[str, Any] | None = None, error: Exception | None = None) -> None: + self.response = response + self.error = error + self.kwargs: dict[str, Any] | None = None + + async def converse(self, **kwargs: Any) -> dict[str, Any]: + self.kwargs = kwargs + if self.error is not None: + raise self.error + return self.response or {} + + +def text_request(text: str = 'hello') -> ModelRequest: + return ModelRequest(messages=[Message(role=Role.USER, content=[Part(root=TextPart(text=text))])]) + + +def text_response(text: str = 'world') -> dict[str, Any]: + return { + 'output': {'message': {'role': 'assistant', 'content': [{'text': text}]}}, + 'stopReason': 'end_turn', + 'usage': {'inputTokens': 1, 'outputTokens': 2, 'totalTokens': 3}, + } + + +@pytest.mark.asyncio +async def test_generate_round_trip() -> None: + transport = FakeTransport(response=text_response('hi there')) + model = BedrockModel(model_id='amazon.nova-lite-v1:0', transport=transport) + + response = await model.generate(text_request()) + + assert transport.kwargs is not None + assert transport.kwargs['modelId'] == 'amazon.nova-lite-v1:0' + assert transport.kwargs['messages'] == [{'role': 'user', 'content': [{'text': 'hello'}]}] + assert response.message is not None + assert response.message.content[0].root.text == 'hi there' + assert response.finish_reason == FinishReason.STOP + assert response.usage is not None + assert response.usage.total_tokens == 3 + + +@pytest.mark.asyncio +async def test_streaming_context_receives_single_bridge_chunk() -> None: + transport = FakeTransport(response=text_response('streamed')) + model = BedrockModel(model_id='amazon.nova-lite-v1:0', transport=transport) + chunks = [] + ctx = ActionRunContext(streaming_callback=chunks.append) + + response = await model.generate(text_request(), ctx) + + assert len(chunks) == 1 + assert chunks[0].content[0].root.text == 'streamed' + assert response.message is not None + assert response.message.content[0].root.text == 'streamed' + + +@pytest.mark.asyncio +async def test_non_streaming_context_sends_no_chunks(monkeypatch: pytest.MonkeyPatch) -> None: + transport = FakeTransport(response=text_response()) + model = BedrockModel(model_id='amazon.nova-lite-v1:0', transport=transport) + ctx = ActionRunContext() + # Recorded directly: a context with no callback reports is_streaming False, + # so only spying on send_chunk proves the guard is what suppresses chunks. + sent: list[Any] = [] + monkeypatch.setattr(ctx, 'send_chunk', sent.append) + + await model.generate(text_request(), ctx) + + assert sent == [] + + +@pytest.mark.parametrize( + 'error,expected_status', + [ + (ParamValidationError(report='bad param'), 'INVALID_ARGUMENT'), + (NoCredentialsError(), 'UNAUTHENTICATED'), + (ReadTimeoutError(endpoint_url='https://bedrock-runtime.us-east-1.amazonaws.com'), 'DEADLINE_EXCEEDED'), + (EndpointConnectionError(endpoint_url='https://bedrock-runtime.us-east-1.amazonaws.com'), 'UNAVAILABLE'), + (BotoCoreError(), 'UNKNOWN'), + ], + ids=['param_validation', 'no_credentials', 'read_timeout', 'endpoint_connection', 'unlisted'], +) +@pytest.mark.asyncio +async def test_botocore_errors_map_to_genkit_statuses(error: BotoCoreError, expected_status: str) -> None: + transport = FakeTransport(error=error) + model = BedrockModel(model_id='amazon.nova-lite-v1:0', transport=transport) + + with pytest.raises(GenkitError) as excinfo: + await model.generate(text_request()) + + assert excinfo.value.status == expected_status + assert 'bedrock converse failed' in excinfo.value.original_message + assert excinfo.value.__cause__ is error + + +@pytest.mark.parametrize( + 'code,expected_status', + [ + ('ThrottlingException', 'RESOURCE_EXHAUSTED'), + ('ValidationException', 'INVALID_ARGUMENT'), + ('AccessDeniedException', 'PERMISSION_DENIED'), + ('ResourceNotFoundException', 'NOT_FOUND'), + ('ModelTimeoutException', 'DEADLINE_EXCEEDED'), + ('ServiceUnavailableException', 'UNAVAILABLE'), + ('SomeFutureException', 'UNKNOWN'), + ], +) +@pytest.mark.asyncio +async def test_client_errors_map_to_genkit_statuses(code, expected_status) -> None: + error = ClientError({'Error': {'Code': code, 'Message': 'nope'}}, 'Converse') + transport = FakeTransport(error=error) + model = BedrockModel(model_id='amazon.nova-lite-v1:0', transport=transport) + + with pytest.raises(GenkitError) as excinfo: + await model.generate(text_request()) + + assert excinfo.value.status == expected_status + assert 'bedrock converse failed' in excinfo.value.original_message + assert excinfo.value.__cause__ is error diff --git a/py/packages/genkit-amazon-bedrock/tests/plugin_test.py b/py/packages/genkit-amazon-bedrock/tests/plugin_test.py new file mode 100644 index 0000000000..0b57179041 --- /dev/null +++ b/py/packages/genkit-amazon-bedrock/tests/plugin_test.py @@ -0,0 +1,135 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the Amazon Bedrock plugin wiring.""" + +from types import SimpleNamespace +from typing import Any, cast + +import boto3.session +import pytest +from genkit_amazon_bedrock import Bedrock, BedrockConfig, ModelDefinition, bedrock_name + +from genkit.plugin_api import ActionKind, GenkitError + + +def test_plugin_name() -> None: + plugin = Bedrock() + assert plugin.name == 'bedrock' + + +def test_bedrock_name_prefixes_model_id() -> None: + assert bedrock_name('anthropic.claude-sonnet-4-5-20250929-v1:0') == ( + 'bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0' + ) + + +def test_constructor_defaults() -> None: + plugin = Bedrock() + # No default region: resolution falls to the SDK chain and fails loudly. + assert plugin.region is None + assert plugin.max_retries == 3 + assert plugin.read_timeout == 3600.0 + assert plugin.connect_timeout == 60.0 + assert plugin.max_pool_connections == 50 + assert plugin.models == [] + assert plugin.embedders == [] + + +def test_model_definition_defaults_to_chat() -> None: + model = ModelDefinition(name='amazon.nova-lite-v1:0') + assert model.type == 'chat' + + +def test_config_accepts_camel_case_and_extra_fields() -> None: + config = BedrockConfig.model_validate({ + 'toolChoice': 'auto', + 'maxTokens': 128, + 'additionalModelRequestFields': {'thinking': {'type': 'enabled'}}, + 'someFutureKnob': True, + }) + assert config.tool_choice == 'auto' + assert config.max_tokens == 128 + assert config.additional_model_request_fields == {'thinking': {'type': 'enabled'}} + + +@pytest.mark.asyncio +async def test_init_returns_no_eager_actions() -> None: + plugin = Bedrock(region='us-east-1') + assert await plugin.init() == [] + + +@pytest.mark.asyncio +async def test_init_fails_loudly_without_region() -> None: + # A stub session isolates the test from ambient AWS env/config. + stub_session = cast(boto3.session.Session, SimpleNamespace(region_name=None)) + plugin = Bedrock(session=stub_session) + with pytest.raises(GenkitError, match='no AWS region resolved') as excinfo: + await plugin.init() + assert excinfo.value.status == 'FAILED_PRECONDITION' + + +@pytest.mark.asyncio +async def test_resolve_returns_model_action_for_any_model_id() -> None: + plugin = Bedrock(region='us-east-1') + action = await plugin.resolve(ActionKind.MODEL, bedrock_name('amazon.nova-lite-v1:0')) + assert action is not None + assert action.name == 'bedrock/amazon.nova-lite-v1:0' + assert action.metadata is not None + model_metadata = cast(dict[str, Any], action.metadata['model']) + assert model_metadata['supports']['tools'] is True + assert model_metadata['customOptions']['properties'].get('toolChoice') is not None + + +@pytest.mark.asyncio +async def test_resolve_ignores_non_model_kinds() -> None: + plugin = Bedrock(region='us-east-1') + assert await plugin.resolve(ActionKind.FLOW, 'bedrock/whatever') is None + + +@pytest.mark.asyncio +async def test_resolve_skips_non_chat_model_definitions() -> None: + plugin = Bedrock( + region='us-east-1', + models=[ModelDefinition(name='amazon.titan-image-generator-v1', type='image')], + ) + assert await plugin.resolve(ActionKind.MODEL, bedrock_name('amazon.titan-image-generator-v1')) is None + + +@pytest.mark.asyncio +async def test_text_type_routes_like_chat() -> None: + plugin = Bedrock( + region='us-east-1', + models=[ModelDefinition(name='meta.llama3-8b-instruct-v1:0', type='text')], + ) + action = await plugin.resolve(ActionKind.MODEL, bedrock_name('meta.llama3-8b-instruct-v1:0')) + assert action is not None + actions = await plugin.list_actions() + assert [a.name for a in actions] == ['bedrock/meta.llama3-8b-instruct-v1:0'] + + +@pytest.mark.asyncio +async def test_list_actions_lists_configured_chat_models() -> None: + plugin = Bedrock( + region='us-east-1', + models=[ + ModelDefinition(name='amazon.nova-lite-v1:0'), + ModelDefinition(name='amazon.titan-image-generator-v1', type='image'), + ], + ) + actions = await plugin.list_actions() + assert [a.name for a in actions] == ['bedrock/amazon.nova-lite-v1:0'] + assert actions[0].action_type == ActionKind.MODEL diff --git a/py/packages/genkit-amazon-bedrock/tests/request_validation_test.py b/py/packages/genkit-amazon-bedrock/tests/request_validation_test.py new file mode 100644 index 0000000000..84636834e7 --- /dev/null +++ b/py/packages/genkit-amazon-bedrock/tests/request_validation_test.py @@ -0,0 +1,170 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Validates built requests against the bedrock-runtime service model. + +Several Converse fields are ``NonEmptyString`` (``min: 1``) and botocore +enforces that client-side, so an empty value fails the call before it reaches +AWS. These tests run the real validator over the request builder's output, which +catches that whole class of defect without credentials or network access. +""" + +import base64 + +import botocore.session +import pytest +from botocore.validate import ParamValidator +from genkit_amazon_bedrock.config import BedrockConfig +from genkit_amazon_bedrock.converters import build_converse_request, cache_point_part + +from genkit import ( + Message, + ModelRequest, + Part, + ReasoningPart, + Role, + TextPart, + ToolDefinition, +) + +CONVERSE_INPUT_SHAPE = ( + botocore.session.get_session().get_service_model('bedrock-runtime').operation_model('Converse').input_shape +) + +PNG_B64 = base64.b64encode(b'\x89PNG\r\n\x1a\nfakeimagedata').decode() + + +def assert_valid_converse_request(request: ModelRequest) -> dict: + """Builds the request and asserts botocore accepts every parameter.""" + kwargs = build_converse_request('amazon.nova-lite-v1:0', request) + report = ParamValidator().validate(kwargs, CONVERSE_INPUT_SHAPE) + assert not report.has_errors(), report.generate_report() + return kwargs + + +def test_undocumented_tool_passes_validation() -> None: + # A tool declared without a docstring reaches the plugin with description + # '', which the NonEmptyString floor on toolSpec.description rejects. + request = ModelRequest( + messages=[Message(role=Role.USER, content=[Part(root=TextPart(text='hi'))])], + tools=[ToolDefinition(name='noop', description='', input_schema={'type': 'object', 'properties': {}})], + ) + kwargs = assert_valid_converse_request(request) + assert 'description' not in kwargs['toolConfig']['tools'][0]['toolSpec'] + + +def test_blank_system_prompt_passes_validation() -> None: + request = ModelRequest( + messages=[ + Message(role=Role.SYSTEM, content=[Part(root=TextPart(text=''))]), + Message(role=Role.USER, content=[Part(root=TextPart(text='hi'))]), + ] + ) + assert_valid_converse_request(request) + + +def test_empty_assistant_text_passes_validation() -> None: + # to_model_response emits a '' text part for guardrail-blocked responses; + # replaying it must stay valid (ContentBlock.text has no floor). + request = ModelRequest( + messages=[ + Message(role=Role.USER, content=[Part(root=TextPart(text='hi'))]), + Message(role=Role.MODEL, content=[Part(root=TextPart(text=''))]), + Message(role=Role.USER, content=[Part(root=TextPart(text='again'))]), + ] + ) + assert_valid_converse_request(request) + + +def test_tool_round_trip_passes_validation() -> None: + request = ModelRequest( + messages=[ + Message(role=Role.USER, content=[Part(root=TextPart(text='weather?'))]), + Message( + role=Role.MODEL, + content=[ + Part.model_validate({ + 'toolRequest': {'ref': 'call-1', 'name': 'weather', 'input': {'city': 'Lagos'}} + }) + ], + ), + Message( + role=Role.TOOL, + content=[ + Part.model_validate({'toolResponse': {'ref': 'call-1', 'name': 'weather', 'output': {'c': 21}}}) + ], + ), + ], + tools=[ + ToolDefinition( + name='weather', + description='Get the weather', + input_schema={'type': 'object', 'properties': {'city': {'type': 'string'}}}, + ) + ], + ) + assert_valid_converse_request(request) + + +def test_media_and_cache_points_pass_validation() -> None: + request = ModelRequest( + messages=[ + Message(role=Role.SYSTEM, content=[Part(root=TextPart(text='rules')), cache_point_part()]), + Message( + role=Role.USER, + content=[ + Part.model_validate({'media': {'url': f'data:image/png;base64,{PNG_B64}'}}), + Part.model_validate({'media': {'url': f'data:application/pdf;base64,{PNG_B64}'}}), + Part(root=TextPart(text='what is this?')), + cache_point_part(), + ], + ), + ] + ) + assert_valid_converse_request(request) + + +def test_reasoning_replay_passes_validation() -> None: + request = ModelRequest( + messages=[ + Message(role=Role.USER, content=[Part(root=TextPart(text='think'))]), + Message( + role=Role.MODEL, + content=[ + Part( + root=ReasoningPart( + reasoning='step one', + metadata={'bedrockReasoningSignature': 'sig-abc', 'signature': 'sig-abc'}, + ) + ), + Part(root=TextPart(text='done')), + ], + ), + Message(role=Role.USER, content=[Part(root=TextPart(text='continue'))]), + ] + ) + assert_valid_converse_request(request) + + +@pytest.mark.parametrize('model_id', ['anthropic.claude-sonnet-4-5-20250929-v1:0', 'amazon.nova-lite-v1:0']) +def test_inference_config_passes_validation(model_id: str) -> None: + request = ModelRequest( + messages=[Message(role=Role.USER, content=[Part(root=TextPart(text='hi'))])], + config=BedrockConfig(temperature=0.0, top_p=0.9, max_tokens=256, stop_sequences=['STOP']), + ) + kwargs = build_converse_request(model_id, request) + report = ParamValidator().validate(kwargs, CONVERSE_INPUT_SHAPE) + assert not report.has_errors(), report.generate_report() diff --git a/py/packages/genkit-amazon-bedrock/tests/transport_test.py b/py/packages/genkit-amazon-bedrock/tests/transport_test.py new file mode 100644 index 0000000000..23d2206638 --- /dev/null +++ b/py/packages/genkit-amazon-bedrock/tests/transport_test.py @@ -0,0 +1,97 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Region resolution tests. Client construction needs no credentials.""" + +import boto3.session +import pytest +from genkit_amazon_bedrock.transport import BedrockTransport + +from genkit.plugin_api import GenkitError + +REGION_ENV_VARS = ('AWS_REGION', 'AWS_DEFAULT_REGION', 'AWS_PROFILE', 'AWS_CONFIG_FILE') + + +@pytest.fixture(autouse=True) +def _isolate_aws_env(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + """Drops ambient AWS config so the tests see only what they set.""" + for name in REGION_ENV_VARS: + monkeypatch.delenv(name, raising=False) + # Points botocore at an empty config file rather than the developer's own. + empty_config = tmp_path / 'aws-config' + empty_config.write_text('') + monkeypatch.setenv('AWS_CONFIG_FILE', str(empty_config)) + + +def make_transport(**kwargs) -> BedrockTransport: + defaults = { + 'max_retries': 3, + 'read_timeout': 3600.0, + 'connect_timeout': 60.0, + 'max_pool_connections': 50, + } + return BedrockTransport(**{**defaults, **kwargs}) + + +def test_explicit_region_wins() -> None: + client = make_transport(region='eu-west-1').client() + assert client.meta.region_name == 'eu-west-1' + + +def test_aws_region_env_var_is_honored(monkeypatch: pytest.MonkeyPatch) -> None: + # botocore below 1.41 reads only AWS_DEFAULT_REGION, so the plugin resolves + # AWS_REGION itself; without that this raises FAILED_PRECONDITION. + monkeypatch.setenv('AWS_REGION', 'us-east-2') + assert make_transport().client().meta.region_name == 'us-east-2' + + +def test_aws_default_region_env_var_is_honored(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv('AWS_DEFAULT_REGION', 'ap-south-1') + assert make_transport().client().meta.region_name == 'ap-south-1' + + +def test_aws_region_beats_aws_default_region(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv('AWS_REGION', 'us-east-2') + monkeypatch.setenv('AWS_DEFAULT_REGION', 'ap-south-1') + assert make_transport().client().meta.region_name == 'us-east-2' + + +def test_supplied_session_region_beats_env(monkeypatch: pytest.MonkeyPatch) -> None: + # A caller who configured a session chose that region deliberately. + monkeypatch.setenv('AWS_REGION', 'us-east-2') + session = boto3.session.Session(region_name='sa-east-1') + assert make_transport(session=session).client().meta.region_name == 'sa-east-1' + + +def test_missing_region_fails_loudly() -> None: + with pytest.raises(GenkitError, match='no AWS region resolved') as excinfo: + make_transport().client() + assert excinfo.value.status == 'FAILED_PRECONDITION' + + +def test_client_is_built_once() -> None: + transport = make_transport(region='eu-west-1') + assert transport.client() is transport.client() + + +def test_botocore_config_carries_the_timeouts() -> None: + config = make_transport(region='eu-west-1', read_timeout=1800.0).client().meta.config + assert config.read_timeout == 1800.0 + assert config.connect_timeout == 60.0 + assert config.max_pool_connections == 50 + # botocore normalizes max_attempts to total attempts: 3 retries plus the first call. + assert config.retries['total_max_attempts'] == 4 + assert config.retries['mode'] == 'standard' diff --git a/py/pyproject.toml b/py/pyproject.toml index 4edb81657f..5292786d00 100644 --- a/py/pyproject.toml +++ b/py/pyproject.toml @@ -19,6 +19,7 @@ authors = [{ name = "Google" }] dependencies = [ "dotpromptz==0.1.5", "genkit", + "genkit-amazon-bedrock", "genkit-anthropic", "genkit-openai", "genkit-django", @@ -131,6 +132,7 @@ override-dependencies = ["werkzeug>=3.1.6"] [tool.uv.sources] # Samples (alphabetical by package name from pyproject.toml) agents = { workspace = true } +amazon-bedrock-sample = { workspace = true } anthropic-sample = { workspace = true } basic-flows = { workspace = true } context = { workspace = true } @@ -153,6 +155,7 @@ vertexai-imagen = { workspace = true } # Core packages genkit = { workspace = true } +genkit-amazon-bedrock = { workspace = true } genkit-anthropic = { workspace = true } genkit-openai = { workspace = true } genkit-django = { workspace = true } @@ -355,6 +358,7 @@ root = [ # Core package "packages/genkit/src", # Integration packages + "packages/genkit-amazon-bedrock/src", "packages/genkit-anthropic/src", "packages/genkit-openai/src", "packages/genkit-django/src", @@ -384,6 +388,7 @@ exclude = [ ] extraPaths = [ "packages/genkit/src", + "packages/genkit-amazon-bedrock/src", "packages/genkit-anthropic/src", "packages/genkit-openai/src", "packages/genkit-django/src", @@ -426,6 +431,7 @@ project_includes = [ search-path = [ ".", "packages/genkit/src", + "packages/genkit-amazon-bedrock/src", "packages/genkit-anthropic/src", "packages/genkit-openai/src", "packages/genkit-django/src", diff --git a/py/samples/amazon-bedrock-sample/README.md b/py/samples/amazon-bedrock-sample/README.md new file mode 100644 index 0000000000..fe650a743d --- /dev/null +++ b/py/samples/amazon-bedrock-sample/README.md @@ -0,0 +1,56 @@ +# Amazon Bedrock + +Run text generation, structured output, tool calling, and reasoning through +Genkit with Amazon Bedrock's Converse API. + +You need an AWS account with Amazon Bedrock model access granted for the four +models the sample uses: + +- `us.amazon.nova-lite-v1:0` +- `us.meta.llama3-3-70b-instruct-v1:0` +- `us.deepseek.r1-v1:0` +- `us.anthropic.claude-sonnet-4-5-20250929-v1:0` + +The Anthropic model additionally needs the account's one-time use-case +agreement (Bedrock console, Model access, Anthropic use case details); the +`thinking` flow fails with `ResourceNotFoundException` until it is granted. + +Credentials come from the standard AWS chain; environment variables, an +`AWS_PROFILE` (including SSO profiles after `aws sso login`), or instance +credentials. A region is required, from `AWS_REGION`, `AWS_DEFAULT_REGION`, or +the active profile: + +```bash +export AWS_PROFILE=my-profile +export AWS_REGION=us-east-1 +``` + +Run the quick smoke test: + +```bash +uv sync +uv run src/main.py +``` + +To explore all flows in Dev UI instead: + +```bash +genkit start -- uv run src/main.py +``` + +Then open [http://localhost:4000](http://localhost:4000) and try: + +- `haiku` +- `cat_profile` +- `weather_report` +- `reasoning` +- `thinking` + +The plugin resolves any Bedrock model ID, inference profile, or ARN on demand, +so the Dev UI model runner also works with models beyond the four declared +ones. + +Bedrock has no constrained-decoding mode, so structured output is carried by +prompt instructions: pass `output_instructions=True` alongside `output_format` +and `output_schema`, as `cat_profile` does. Without it the schema never reaches +the model and it answers in prose. diff --git a/py/samples/amazon-bedrock-sample/pyproject.toml b/py/samples/amazon-bedrock-sample/pyproject.toml new file mode 100644 index 0000000000..c4767970e9 --- /dev/null +++ b/py/samples/amazon-bedrock-sample/pyproject.toml @@ -0,0 +1,17 @@ +[project] +name = "amazon-bedrock-sample" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = [ + "genkit", + "genkit-amazon-bedrock", + "pydantic>=2.10.5", + "structlog>=25.2.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src"] diff --git a/py/samples/amazon-bedrock-sample/src/main.py b/py/samples/amazon-bedrock-sample/src/main.py new file mode 100644 index 0000000000..d4fe85ad6c --- /dev/null +++ b/py/samples/amazon-bedrock-sample/src/main.py @@ -0,0 +1,188 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Amazon Bedrock samples for the non-streaming Converse path. + +Needs AWS credentials and a region (``AWS_REGION`` or ``~/.aws/config``) with +model access granted for the three models below. Streaming is not wired up yet, +so every flow here is a single Converse call. +""" + +from genkit_amazon_bedrock import Bedrock, ModelDefinition +from pydantic import BaseModel, Field + +from genkit import Genkit, ModelResponse, ReasoningPart + +NOVA = 'bedrock/us.amazon.nova-lite-v1:0' +LLAMA = 'bedrock/us.meta.llama3-3-70b-instruct-v1:0' +DEEPSEEK = 'bedrock/us.deepseek.r1-v1:0' +CLAUDE = 'bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0' + +# Declaring models is optional — resolve() serves any Bedrock model ID or ARN +# on demand — but declared ones show up in the Dev UI model list. +ai = Genkit( + plugins=[ + Bedrock( + models=[ + ModelDefinition(name='us.amazon.nova-lite-v1:0'), + ModelDefinition(name='us.meta.llama3-3-70b-instruct-v1:0'), + ModelDefinition(name='us.deepseek.r1-v1:0'), + ModelDefinition(name='us.anthropic.claude-sonnet-4-5-20250929-v1:0'), + ] + ) + ], + model=NOVA, +) + + +class TopicInput(BaseModel): + """Input for a plain-text generation.""" + + topic: str = Field(default='coding', description='Topic for the haiku') + + +class CatInput(BaseModel): + """Input for a structured generation.""" + + name: str = Field(default='Mittens', description='Name of the cat to invent') + + +class Cat(BaseModel): + """Structured cat profile.""" + + name: str + breed: str + age: int + personality: str + + +class CityInput(BaseModel): + """Input for the weather tool.""" + + city: str = Field(default='Lagos', description='City to look up') + + +@ai.tool() +async def current_weather(city_input: CityInput) -> str: + """Return mocked weather data for tool-calling demos.""" + return f'The weather in {city_input.city} is 31C and humid.' + + +@ai.flow() +async def haiku(data: TopicInput) -> str: + """Plain-text generate through Converse.""" + response = await ai.generate(prompt=f'Write a haiku about {data.topic}.') + return response.text + + +@ai.flow() +async def cat_profile(data: CatInput) -> Cat: + """Structured output, carried by prompt instructions. + + Bedrock has no constrained-decoding mode, and the core's json format only + injects the schema when ``output_instructions`` is set, so it is required + here. Model choice matters too: the Nova models answer in prose often + enough to fail extraction, so this uses Llama 3.3. + """ + response = await ai.generate( + model=LLAMA, + prompt=f'Invent a cat named {data.name}.', + output_format='json', + output_schema=Cat, + output_instructions=True, + config={'maxTokens': 1024}, + ) + return response.output + + +@ai.flow() +async def weather_report(data: CityInput) -> str: + """Tool calling: the model calls the tool, then answers from its output.""" + response = await ai.generate( + prompt=f'What is the weather in {data.city}? Use the tool, then answer in one sentence.', + tools=['current_weather'], + ) + return response.text + + +@ai.flow() +async def reasoning(data: TopicInput) -> dict[str, object]: + """Reasoning parts parsed off a Converse response. + + DeepSeek R1 reasons on every turn, so no thinking config is needed. Its + reasoning carries no signature, which is why ``signatures_present`` is + false here: signatures are Anthropic-specific and gate replay. + """ + response = await ai.generate( + model=DEEPSEEK, + prompt=f'What is 17 * 23? Think it through, then state the answer. Mention {data.topic} once.', + config={'maxTokens': 2048}, + ) + return _reasoning_summary(response) + + +@ai.flow() +async def thinking(data: TopicInput) -> dict[str, object]: + """Claude extended thinking: signed reasoning that survives replay. + + Unlike DeepSeek, Claude signs its reasoning, so ``signatures_present`` is + true here and the parts are replayed verbatim on multi-turn follow-ups. + """ + response = await ai.generate( + model=CLAUDE, + prompt=f'What is 17 * 23? Think it through, then state the answer. Mention {data.topic} once.', + config={ + 'maxTokens': 4096, + # Bedrock requires budget_tokens >= 1024, below maxTokens. + 'additionalModelRequestFields': {'thinking': {'type': 'enabled', 'budget_tokens': 1024}}, + }, + ) + return _reasoning_summary(response) + + +def _reasoning_summary(response: ModelResponse) -> dict[str, object]: + """Summarize the reasoning parts on a response.""" + reasoning_text: list[str] = [] + signed: list[bool] = [] + for message in response.messages: + for part in message.content: + root = part.root + if isinstance(root, ReasoningPart): + reasoning_text.append(root.reasoning) + signed.append(bool(root.metadata and root.metadata.get('bedrockReasoningSignature'))) + return { + 'answer': response.text, + 'reasoning_parts': len(reasoning_text), + 'reasoning_preview': ''.join(reasoning_text)[:500], + 'signatures_present': signed, + } + + +async def main() -> None: + """Run the lightweight flows once from the CLI.""" + try: + print(await haiku(TopicInput())) # noqa: T201 + print(await weather_report(CityInput())) # noqa: T201 + except Exception as error: + # Printed, not raised: in dev mode the Dev UI stays up either way. + print( # noqa: T201 + f'Set AWS credentials and a region, and grant model access for {NOVA}, {LLAMA}, and {DEEPSEEK}, ' + f'before running this sample.\n{error}' + ) + + +if __name__ == '__main__': + ai.run_main(main()) diff --git a/py/uv.lock b/py/uv.lock index 4a06d0ca48..9e66a55918 100644 --- a/py/uv.lock +++ b/py/uv.lock @@ -12,6 +12,7 @@ resolution-markers = [ [manifest] members = [ "agents", + "amazon-bedrock-sample", "anthropic-sample", "basic-flows", "context", @@ -22,6 +23,7 @@ members = [ "gemini-code-execution", "gemini-context-caching", "genkit", + "genkit-amazon-bedrock", "genkit-anthropic", "genkit-django", "genkit-evaluators", @@ -98,6 +100,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/33/ef2f2409450ef6daa61459d5de5c08128e7d3edb773fefd0a324d1310238/altair-6.0.0-py3-none-any.whl", hash = "sha256:09ae95b53d5fe5b16987dccc785a7af8588f2dca50de1e7a156efa8a461515f8", size = 795410, upload-time = "2025-11-12T08:59:09.804Z" }, ] +[[package]] +name = "amazon-bedrock-sample" +version = "0.1.0" +source = { editable = "samples/amazon-bedrock-sample" } +dependencies = [ + { name = "genkit" }, + { name = "genkit-amazon-bedrock" }, + { name = "pydantic" }, + { name = "structlog" }, +] + +[package.metadata] +requires-dist = [ + { name = "genkit", editable = "packages/genkit" }, + { name = "genkit-amazon-bedrock", editable = "packages/genkit-amazon-bedrock" }, + { name = "pydantic", specifier = ">=2.10.5" }, + { name = "structlog", specifier = ">=25.2.0" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -456,6 +477,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/ca/78d423b324b8d77900030fa59c4aa9054261ef0925631cd2501dd015b7b7/boolean_py-5.0-py3-none-any.whl", hash = "sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9", size = 26577, upload-time = "2025-04-03T10:39:48.449Z" }, ] +[[package]] +name = "boto3" +version = "1.40.61" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/f9/6ef8feb52c3cce5ec3967a535a6114b57ac7949fd166b0f3090c2b06e4e5/boto3-1.40.61.tar.gz", hash = "sha256:d6c56277251adf6c2bdd25249feae625abe4966831676689ff23b4694dea5b12", size = 111535, upload-time = "2025-10-28T19:26:57.247Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/24/3bf865b07d15fea85b63504856e137029b6acbc73762496064219cdb265d/boto3-1.40.61-py3-none-any.whl", hash = "sha256:6b9c57b2a922b5d8c17766e29ed792586a818098efe84def27c8f582b33f898c", size = 139321, upload-time = "2025-10-28T19:26:55.007Z" }, +] + +[[package]] +name = "botocore" +version = "1.40.61" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/a3/81d3a47c2dbfd76f185d3b894f2ad01a75096c006a2dd91f237dca182188/botocore-1.40.61.tar.gz", hash = "sha256:a2487ad69b090f9cccd64cf07c7021cd80ee9c0655ad974f87045b02f3ef52cd", size = 14393956, upload-time = "2025-10-28T19:26:46.108Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/c5/f6ce561004db45f0b847c2cd9b19c67c6bf348a82018a48cb718be6b58b0/botocore-1.40.61-py3-none-any.whl", hash = "sha256:17ebae412692fd4824f99cde0f08d50126dc97954008e5ba2b522eb049238aa7", size = 14055973, upload-time = "2025-10-28T19:26:42.15Z" }, +] + [[package]] name = "bpython" version = "0.26" @@ -1191,9 +1240,9 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "asgiref", marker = "python_full_version < '3.12'" }, - { name = "sqlparse", marker = "python_full_version < '3.12'" }, - { name = "tzdata", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/65/95/95f7faa0950867afaa0bef2460c6263afd6a2c78cc9434046ed28160b015/django-5.2.14.tar.gz", hash = "sha256:58a63ba841662e5c686b57ba1fec52ddd68c0b93bd96ac3029d55728f00bf8a2", size = 10895118, upload-time = "2026-05-05T13:57:31.104Z" } wheels = [ @@ -1210,9 +1259,9 @@ resolution-markers = [ "python_full_version == '3.12.*'", ] dependencies = [ - { name = "asgiref", marker = "python_full_version >= '3.12'" }, - { name = "sqlparse", marker = "python_full_version >= '3.12'" }, - { name = "tzdata", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5e/f1/bf85f0d29ef76abf901f193fe8fef4769d3da7794197832bc30151c071d8/django-6.0.5.tar.gz", hash = "sha256:bc6d6872e98a2864c836e42edd644b362db311147dd5aa8d5b82ba7a032f5269", size = 10924131, upload-time = "2026-05-05T13:54:39.329Z" } wheels = [ @@ -1374,7 +1423,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1635,6 +1684,21 @@ requires-dist = [ ] provides-extras = ["flask", "google-cloud", "google-genai", "ollama", "openai", "vertex-ai"] +[[package]] +name = "genkit-amazon-bedrock" +version = "0.8.1" +source = { editable = "packages/genkit-amazon-bedrock" } +dependencies = [ + { name = "boto3" }, + { name = "genkit" }, +] + +[package.metadata] +requires-dist = [ + { name = "boto3", specifier = ">=1.37.24" }, + { name = "genkit", editable = "packages/genkit" }, +] + [[package]] name = "genkit-anthropic" version = "0.8.1" @@ -1904,6 +1968,7 @@ source = { virtual = "." } dependencies = [ { name = "dotpromptz" }, { name = "genkit" }, + { name = "genkit-amazon-bedrock" }, { name = "genkit-anthropic" }, { name = "genkit-django" }, { name = "genkit-evaluators" }, @@ -1969,6 +2034,7 @@ lint = [ requires-dist = [ { name = "dotpromptz", specifier = "==0.1.5" }, { name = "genkit", editable = "packages/genkit" }, + { name = "genkit-amazon-bedrock", editable = "packages/genkit-amazon-bedrock" }, { name = "genkit-anthropic", editable = "packages/genkit-anthropic" }, { name = "genkit-django", editable = "packages/genkit-django" }, { name = "genkit-evaluators", editable = "packages/genkit-evaluators" }, @@ -2801,17 +2867,17 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, - { name = "decorator", marker = "python_full_version < '3.11'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "jedi", marker = "python_full_version < '3.11'" }, - { name = "matplotlib-inline", marker = "python_full_version < '3.11'" }, - { name = "pexpect", marker = "python_full_version < '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit", marker = "python_full_version < '3.11'" }, - { name = "pygments", marker = "python_full_version < '3.11'" }, - { name = "stack-data", marker = "python_full_version < '3.11'" }, - { name = "traitlets", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "exceptiongroup" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e5/61/1810830e8b93c72dcd3c0f150c80a00c3deb229562d9423807ec92c3a539/ipython-8.38.0.tar.gz", hash = "sha256:9cfea8c903ce0867cc2f23199ed8545eb741f3a69420bfcf3743ad1cec856d39", size = 5513996, upload-time = "2026-01-05T10:59:06.901Z" } wheels = [ @@ -2829,17 +2895,17 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, - { name = "decorator", marker = "python_full_version >= '3.11'" }, - { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.11'" }, - { name = "jedi", marker = "python_full_version >= '3.11'" }, - { name = "matplotlib-inline", marker = "python_full_version >= '3.11'" }, - { name = "pexpect", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit", marker = "python_full_version >= '3.11'" }, - { name = "pygments", marker = "python_full_version >= '3.11'" }, - { name = "stack-data", marker = "python_full_version >= '3.11'" }, - { name = "traitlets", marker = "python_full_version >= '3.11'" }, - { name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "ipython-pygments-lexers" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7d/ce/012a0f40ca58a966f87a6e894d6828e2817657cbdf522b02a5d3a87d92ce/ipython-9.0.2.tar.gz", hash = "sha256:ec7b479e3e5656bf4f58c652c120494df1820f4f28f522fb7ca09e213c2aab52", size = 4366102, upload-time = "2025-03-08T15:04:52.885Z" } wheels = [ @@ -2851,7 +2917,7 @@ name = "ipython-pygments-lexers" version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } wheels = [ @@ -2979,7 +3045,7 @@ name = "jinxed" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ansicon", marker = "sys_platform == 'win32'" }, + { name = "ansicon" }, ] sdist = { url = "https://files.pythonhosted.org/packages/20/d0/59b2b80e7a52d255f9e0ad040d2e826342d05580c4b1d7d7747cfb8db731/jinxed-1.3.0.tar.gz", hash = "sha256:1593124b18a41b7a3da3b078471442e51dbad3d77b4d4f2b0c26ab6f7d660dbf", size = 80981, upload-time = "2024-07-31T22:39:18.854Z" } wheels = [ @@ -3083,6 +3149,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" }, ] +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + [[package]] name = "json5" version = "0.13.0" @@ -6151,6 +6226,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" }, ] +[[package]] +name = "s3transfer" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/74/8d69dcb7a9efe8baa2046891735e5dfe433ad558ae23d9e3c14c633d1d58/s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125", size = 151547, upload-time = "2025-09-09T19:23:31.089Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/f0/ae7ca09223a81a1d890b2557186ea015f6e0502e9b8cb8e1813f1d8cfa4e/s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456", size = 85712, upload-time = "2025-09-09T19:23:30.041Z" }, +] + [[package]] name = "secretstorage" version = "3.5.0" @@ -6372,8 +6459,8 @@ name = "taskgroup" version = "0.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "exceptiongroup" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f0/8d/e218e0160cc1b692e6e0e5ba34e8865dbb171efeb5fc9a704544b3020605/taskgroup-0.2.2.tar.gz", hash = "sha256:078483ac3e78f2e3f973e2edbf6941374fbea81b9c5d0a96f51d297717f4752d", size = 11504, upload-time = "2025-01-03T09:24:13.761Z" } wheels = [