Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
30 changes: 30 additions & 0 deletions chatlas/_provider_bedrock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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(
*,
Expand Down
93 changes: 92 additions & 1 deletion tests/test_provider_bedrock_mantle.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import base64
import json
from datetime import date
from importlib import resources

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,
Expand Down Expand Up @@ -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")
Expand Down
Loading