From fd5aebc1f41abcd2cffdf2d089c0abb217fe8bf8 Mon Sep 17 00:00:00 2001 From: taekop Date: Mon, 28 Sep 2026 16:25:54 +0900 Subject: [PATCH] fix(bedrock): send bytes instead of URLs for PDF and document content --- CHANGELOG.md | 1 + chatlas/_provider_bedrock.py | 30 +++++++++ tests/test_provider_bedrock_mantle.py | 93 ++++++++++++++++++++++++++- 3 files changed, 123 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64cd471d..7d7c5984 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Bug fixes +* `ChatBedrock()` (with `api="messages"` or `api="responses"`) no longer sends a URL for `content_pdf_url()`/`content_document_url()` content, which `bedrock-mantle` rejects since it can't fetch URLs itself. The bytes are downloaded and sent instead, as they already were before URL passthrough was added. (#410) * `ChatBedrock()` (with the default `api="converse"`) no longer sends assistant turns with an empty `content` array, which Converse rejects. This happens when a response carries no content blocks, for example when a guardrail intervenes before the model produces any. A `"[empty string]"` placeholder is sent instead, matching how empty text content is already normalized. (#426) * `ChatPosit()` now handles setting `chat.model` to a model from the other family (Claude vs. non-Claude): the underlying provider is swapped so requests go to the correct endpoint, instead of failing with a mismatched request. The `cache` setting is preserved across switches. * Anthropic-backed providers (`ChatAnthropic()`, `ChatPosit()`, `ChatBedrock()`, etc.) no longer fail with `Invalid signature in thinking block` when the conversation history contains reasoning from a non-Claude model (e.g., after switching `chat.model` across families); such thinking is now replayed as plain text so the model can still see it. diff --git a/chatlas/_provider_bedrock.py b/chatlas/_provider_bedrock.py index 772f9571..44d969a5 100644 --- a/chatlas/_provider_bedrock.py +++ b/chatlas/_provider_bedrock.py @@ -9,17 +9,23 @@ import httpx from ._chat import Chat +from ._content import ContentDocument, ContentPDF +from ._content_file import FileContent, ensure_bytes from ._logging import log_model_default from ._provider import ModelInfo, no_file_management from ._provider_anthropic import AnthropicProvider from ._provider_openai import OpenAIProvider from ._provider_openai_generic import openai_models_to_info +from ._turn import Turn from ._utils import MISSING, MISSING_TYPE, AnyTypeDict, split_http_client_kwargs if TYPE_CHECKING: from botocore.credentials import Credentials from botocore.session import Session + from ._content import Content + from ._provider_anthropic import ContentBlockParam + from ._provider_openai import ResponseInputItemParam from .types.anthropic import ChatClientArgs as AnthropicClientArgs from .types.bedrock import ChatClientArgs as ConverseClientArgs from .types.openai import ChatClientArgs as OpenAIClientArgs @@ -279,6 +285,15 @@ def list_models(self) -> list[ModelInfo]: models_client = self._client.with_options(base_url=self._models_base_url) return openai_models_to_info(models_client.models.list(), self.name) + def _turns_as_inputs(self, turns: list[Turn]) -> "list[ResponseInputItemParam]": + turns = [ + turn.model_copy( + update={"contents": [bedrock_materialize_url(c) for c in turn.contents]} + ) + for turn in turns + ] + return super()._turns_as_inputs(turns) + @no_file_management class BedrockMessagesProvider(AnthropicProvider): @@ -348,6 +363,21 @@ def __init__( def list_models(self) -> list[ModelInfo]: return openai_models_to_info(self._models_client.models.list(), self.name) + @staticmethod + def _as_content_block(content: "Content") -> "ContentBlockParam": + if isinstance(content, ContentPDF): + content = bedrock_materialize_url(content) + return AnthropicProvider._as_content_block(content) + + +def bedrock_materialize_url(content: "Content") -> "Content": + """Swap URL content for its bytes, since bedrock-mantle can't fetch URLs.""" + if not isinstance(content, (ContentPDF, ContentDocument)) or content.url is None: + return content + kind = "PDF" if isinstance(content, ContentPDF) else "document" + data = ensure_bytes(cast(FileContent, content), kind) + return content.model_copy(update={"data": data, "url": None}) + def bedrock_client_kwargs( *, diff --git a/tests/test_provider_bedrock_mantle.py b/tests/test_provider_bedrock_mantle.py index e0928954..8a09f2de 100644 --- a/tests/test_provider_bedrock_mantle.py +++ b/tests/test_provider_bedrock_mantle.py @@ -1,3 +1,4 @@ +import base64 import json from datetime import date from importlib import resources @@ -5,7 +6,8 @@ import httpx import httpx2 import pytest -from chatlas import Chat, ChatBedrock +from chatlas import Chat, ChatBedrock, UserTurn +from chatlas._content import ContentDocument, ContentPDF from chatlas._provider_bedrock import ( bedrock_api_for_model, bedrock_base_url, @@ -322,6 +324,95 @@ def test_list_models_returns_the_combined_listing(self, monkeypatch): ] +class TestUrlContentFallsBackToBytes: + """Mantle can't fetch URLs, so PDF/document content built with a URL must + be sent as bytes instead (https://github.com/posit-dev/chatlas/issues/410). + """ + + def test_messages_pdf_url_downloads_bytes_instead(self, monkeypatch): + from chatlas._provider_bedrock import BedrockMessagesProvider + + monkeypatch.setattr( + "chatlas._content_file.download_bytes", lambda url: b"%PDF-1.4 fake" + ) + c = ContentPDF(filename="a.pdf", url="https://example.com/a.pdf") + + block = BedrockMessagesProvider._as_content_block(c) + + assert block["source"] == { + "type": "base64", + "media_type": "application/pdf", + "data": base64.b64encode(b"%PDF-1.4 fake").decode("utf-8"), + } + # The downloaded bytes are cached back onto the original content. + assert c.data == b"%PDF-1.4 fake" + + def test_messages_pdf_with_data_ignores_url_without_downloading(self, monkeypatch): + from chatlas._provider_bedrock import BedrockMessagesProvider + + def fail_download(url): + raise AssertionError("shouldn't download when data is already present") + + monkeypatch.setattr("chatlas._content_file.download_bytes", fail_download) + c = ContentPDF( + data=b"%PDF-1.4", filename="a.pdf", url="https://example.com/a.pdf" + ) + + block = BedrockMessagesProvider._as_content_block(c) + + assert block["source"]["type"] == "base64" + assert block["source"]["data"] == base64.b64encode(b"%PDF-1.4").decode("utf-8") + + def test_responses_pdf_and_document_urls_download_bytes_instead(self, monkeypatch): + monkeypatch.setattr( + "chatlas._content_file.download_bytes", lambda url: b"downloaded" + ) + chat = ChatBedrock(model="openai.gpt-5.6-sol", aws_region="us-east-1") + pdf = ContentPDF(filename="a.pdf", url="https://example.com/a.pdf") + doc = ContentDocument( + filename="notes.txt", + mime_type="text/plain", + url="https://example.com/notes.txt", + ) + turn = UserTurn([pdf, doc]) + + inputs = chat.provider._turns_as_inputs([turn]) + + expected_data = ( + f"data:application/pdf;base64," + f"{base64.b64encode(b'downloaded').decode('utf-8')}" + ) + for item in inputs: + part = item["content"][0] + assert part["type"] == "input_file" + assert "file_url" not in part + assert part["file_data"].startswith("data:") + assert inputs[0]["content"][0]["file_data"] == expected_data + # Downloaded bytes are cached back onto the original content objects. + assert pdf.data == b"downloaded" + assert doc.data == b"downloaded" + + def test_responses_pdf_with_data_ignores_url_without_downloading(self, monkeypatch): + def fail_download(url): + raise AssertionError("shouldn't download when data is already present") + + monkeypatch.setattr("chatlas._content_file.download_bytes", fail_download) + chat = ChatBedrock(model="openai.gpt-5.6-sol", aws_region="us-east-1") + pdf = ContentPDF( + data=b"%PDF-1.4", filename="a.pdf", url="https://example.com/a.pdf" + ) + turn = UserTurn([pdf]) + + inputs = chat.provider._turns_as_inputs([turn]) + + part = inputs[0]["content"][0] + assert part["type"] == "input_file" + assert "file_url" not in part + assert part["file_data"] == ( + f"data:application/pdf;base64,{base64.b64encode(b'%PDF-1.4').decode('utf-8')}" + ) + + class TestNativeSdkClients: def test_responses_default_clients_stabilize_the_connection_header(self): chat = ChatBedrock(model="openai.gpt-5.6-sol", aws_region="us-east-1")