Skip to content

Commit 03e6417

Browse files
mdrxyopen-swe
andauthored
fix(vertexai): convert file content blocks to Anthropic documents (#2057)
Anthropic has no `file` content type, so LangChain `file` blocks sent through `ChatAnthropicVertex` (e.g. a base64 PDF from a user or a tool) were forwarded as-is and rejected by the API. This converts `file` and `text-plain` blocks to Anthropic `document` blocks in user messages and tool results, ported from `langchain-anthropic`'s `_format_data_content_block` (no dependency on that package, matching how image blocks are already handled here). ## References - [Slack thread](https://langchain.slack.com/archives/C07D416CCTU/p1791902852491299?thread_ts=1791902852.491299&cid=C07D416CCTU) Made by [Open SWE](https://github.com/langchain-ai/open-swe) · [view thread](https://openswe.langchain.dev/agents/3932fde8-b58e-574f-9224-29be66a96034) · openai:gpt-6.1-sol (low) --------- Co-authored-by: Mason Daugherty <mdrxy@users.noreply.github.com> Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
1 parent 69361c5 commit 03e6417

2 files changed

Lines changed: 290 additions & 2 deletions

File tree

‎libs/vertexai/langchain_google_vertexai/_anthropic_utils.py‎

Lines changed: 127 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,16 @@ def _format_message_anthropic(
190190
content.append(_format_image_content_block(block, project))
191191
continue
192192

193+
if block["type"] in ("file", "text-plain"):
194+
content.append(_format_document_content_block(block))
195+
continue
196+
197+
# Tool results may nest LangChain file/image blocks (e.g. a PDF
198+
# returned by a tool), which Anthropic rejects unconverted
199+
if block["type"] == "tool_result":
200+
content.append(_clean_content_block(block))
201+
continue
202+
193203
if block["type"] == "text":
194204
text: str = block.get("text", "")
195205
# Only add non-empty strings for now as empty ones are not
@@ -394,24 +404,139 @@ def _format_image_content_block(block: dict, project: str | None = None) -> dict
394404
raise ValueError(msg)
395405

396406

407+
def _text_document_source(block: dict) -> dict[str, Any]:
408+
"""Build an Anthropic plain-text document source.
409+
410+
Raises:
411+
ValueError: If the block has no inline `text`, since Anthropic text
412+
document sources cannot reference a URL, file ID, or base64 payload.
413+
"""
414+
if "text" not in block:
415+
msg = (
416+
"Anthropic plain-text document sources require an inline 'text' field; "
417+
f"got keys {sorted(block)}."
418+
)
419+
raise ValueError(msg)
420+
return {
421+
"type": "text",
422+
"media_type": block.get("mime_type") or "text/plain",
423+
"data": block["text"],
424+
}
425+
426+
427+
def _format_document_source(block: dict) -> dict[str, Any]:
428+
"""Build the Anthropic document source for a LangChain file block.
429+
430+
Follows `langchain_anthropic`, accepting both standard and legacy
431+
(`source_type`) block shapes, but validates sources more strictly: non-PDF
432+
base64 or URL files and empty base64 payloads are rejected locally rather
433+
than failing at the API.
434+
435+
`url` and `file_id` sources are still converted for parity, although Claude
436+
on Vertex AI does not currently support URL or Files API input sources, so
437+
the API is expected to reject them.
438+
439+
Raises:
440+
ValueError: If a `file` block has no supported data field, a base64 or
441+
URL `file` block is not a PDF, a base64 payload is empty, or a
442+
plain-text block has no inline `text`.
443+
"""
444+
if block["type"] == "text-plain":
445+
return _text_document_source(block)
446+
if "url" in block:
447+
# Anthropic URL document sources only accept PDFs; `mime_type` is
448+
# optional on URL blocks, so only reject an explicit mismatch
449+
mime_type = block.get("mime_type")
450+
if mime_type and mime_type != "application/pdf":
451+
msg = (
452+
"Anthropic only supports URL file content blocks with mime_type "
453+
f"'application/pdf', got {mime_type!r}. Use an 'image' block for "
454+
"image URLs."
455+
)
456+
raise ValueError(msg)
457+
return {"type": "url", "url": block["url"]}
458+
if "base64" in block or block.get("source_type") == "base64":
459+
media_type = block.get("mime_type") or "application/pdf"
460+
# Anthropic base64 document sources only accept PDFs
461+
if media_type != "application/pdf":
462+
msg = (
463+
"Anthropic only supports base64 file content blocks with mime_type "
464+
f"'application/pdf', got {media_type!r}. Use a 'text-plain' block "
465+
"for plain text or an 'image' block for images."
466+
)
467+
raise ValueError(msg)
468+
data = block["base64"] if "base64" in block else block.get("data")
469+
if not data:
470+
msg = "Base64 file content block is missing its 'base64' or 'data' payload."
471+
raise ValueError(msg)
472+
return {"type": "base64", "media_type": media_type, "data": data}
473+
if block.get("source_type") == "text":
474+
return _text_document_source(block)
475+
if "file_id" in block:
476+
return {"type": "file", "file_id": block["file_id"]}
477+
if block.get("source_type") == "id" and "id" in block:
478+
return {"type": "file", "file_id": block["id"]}
479+
msg = (
480+
"Anthropic file content blocks require a 'url', 'base64', or 'file_id' "
481+
"key (or legacy source_type 'base64', 'text', or 'id'). Use a "
482+
"'text-plain' block for plain text."
483+
)
484+
raise ValueError(msg)
485+
486+
487+
def _format_document_content_block(block: dict) -> dict:
488+
"""Convert LangChain `file` and `text-plain` blocks to Anthropic documents.
489+
490+
Anthropic has no `file` content type, so these blocks must be converted
491+
before reaching the API.
492+
493+
Raises:
494+
ValueError: If the block cannot be converted to an Anthropic document
495+
source.
496+
"""
497+
formatted_block: dict[str, Any] = {
498+
"type": "document",
499+
"source": _format_document_source(block),
500+
}
501+
for key in ("cache_control", "citations", "title", "context"):
502+
if key in block:
503+
formatted_block[key] = block[key]
504+
elif key in (block.get("extras") or {}):
505+
formatted_block[key] = block["extras"][key]
506+
elif key in (block.get("metadata") or {}):
507+
formatted_block[key] = block["metadata"][key]
508+
return formatted_block
509+
510+
397511
def _clean_content_block(block: Any) -> Any:
398-
"""Remove streaming metadata fields from content blocks.
512+
"""Prepare a content block to be sent back to the Anthropic API.
399513
400514
Anthropic's streaming API adds `index` and `partial_json` fields to content blocks
401515
during streaming. These fields must be removed before sending back to the API.
516+
LangChain `image`, `file`, and `text-plain` blocks are also converted to Anthropic
517+
wire format, including when nested inside `tool_result` content.
402518
403519
Args:
404520
block: Content block (`dict`, `str`, or other type)
405521
406522
Returns:
407523
Cleaned content block with streaming metadata removed
524+
525+
Raises:
526+
ValueError: If a LangChain `image`, `file`, or `text-plain` block cannot be
527+
converted to Anthropic wire format.
408528
"""
409529
if not isinstance(block, dict):
410530
return block
411531

412-
# Convert LangChain image blocks to Anthropic wire format
532+
# Convert LangChain image, file, and text-plain blocks to Anthropic wire format
413533
if block.get("type") == "image":
414534
return _format_image_content_block(block)
535+
if block.get("type") in ("file", "text-plain"):
536+
return _format_document_content_block(block)
537+
# Tool results may nest LangChain blocks that also need converting
538+
if block.get("type") == "tool_result" and "content" in block:
539+
block = {**block, "content": _clean_content(block["content"])}
415540

416541
# Remove known streaming metadata fields
417542
# 'index' - added during streaming to track block position

‎libs/vertexai/tests/unit_tests/test_anthropic_utils.py‎

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Unit tests for _anthropic_utils.py."""
22

33
import base64
4+
from typing import Any
45
from unittest.mock import patch
56

67
import pytest
@@ -1582,6 +1583,168 @@ def test_format_image(image_url: str, expected_media_type: str) -> None:
15821583
mock_loader_instance.load_bytes.assert_called_once_with(image_url)
15831584

15841585

1586+
@pytest.mark.parametrize("message_kind", ["user", "tool", "nested_tool", "nested_user"])
1587+
def test_format_messages_base64_pdf_file(message_kind: str) -> None:
1588+
pdf_block = {
1589+
"type": "file",
1590+
"base64": "cGRm",
1591+
"mime_type": "application/pdf",
1592+
"id": "lc-file-id",
1593+
"title": "Report",
1594+
"extras": {"citations": {"enabled": True}, "context": "Quarterly report"},
1595+
}
1596+
native_document = {
1597+
"type": "document",
1598+
"source": {
1599+
"type": "base64",
1600+
"media_type": "application/pdf",
1601+
"data": "bmF0aXZl",
1602+
},
1603+
"title": "Native report",
1604+
"citations": {"enabled": True},
1605+
}
1606+
content: list[str | dict[str, Any]] = [
1607+
{"type": "text", "text": "Report attached"},
1608+
pdf_block,
1609+
native_document,
1610+
]
1611+
tool_result = {
1612+
"type": "tool_result",
1613+
"tool_use_id": "call_1",
1614+
"content": content,
1615+
"is_error": True,
1616+
"cache_control": {"type": "ephemeral"},
1617+
}
1618+
message: HumanMessage | ToolMessage
1619+
if message_kind == "user":
1620+
message = HumanMessage(content=content)
1621+
elif message_kind == "nested_user":
1622+
message = HumanMessage(content=[tool_result])
1623+
else:
1624+
message = ToolMessage(
1625+
content=[tool_result] if message_kind == "nested_tool" else content,
1626+
tool_call_id="call_1",
1627+
status="error",
1628+
additional_kwargs={"cache_control": {"type": "ephemeral"}},
1629+
)
1630+
original = message.model_copy(deep=True)
1631+
1632+
_, formatted = _format_messages_anthropic([message], project="test-project")
1633+
1634+
expected_content = [
1635+
content[0],
1636+
{
1637+
"type": "document",
1638+
"source": {
1639+
"type": "base64",
1640+
"media_type": "application/pdf",
1641+
"data": "cGRm",
1642+
},
1643+
"title": "Report",
1644+
"citations": {"enabled": True},
1645+
"context": "Quarterly report",
1646+
},
1647+
native_document,
1648+
]
1649+
if message_kind == "user":
1650+
assert formatted == [{"role": "user", "content": expected_content}]
1651+
else:
1652+
assert formatted == [
1653+
{"role": "user", "content": [{**tool_result, "content": expected_content}]}
1654+
]
1655+
assert message == original
1656+
1657+
1658+
def test_format_messages_pdf_file_defaults() -> None:
1659+
message = HumanMessage(content=[{"type": "file", "base64": "cGRm"}])
1660+
1661+
_, formatted = _format_messages_anthropic([message], project="test-project")
1662+
1663+
assert formatted[0]["content"] == [
1664+
{
1665+
"type": "document",
1666+
"source": {
1667+
"type": "base64",
1668+
"media_type": "application/pdf",
1669+
"data": "cGRm",
1670+
},
1671+
}
1672+
]
1673+
1674+
1675+
def test_format_messages_file_legacy_metadata() -> None:
1676+
message = HumanMessage(
1677+
content=[
1678+
{
1679+
"type": "file",
1680+
"source_type": "base64",
1681+
"data": "cGRm",
1682+
"metadata": {"title": "Report"},
1683+
"cache_control": {"type": "ephemeral"},
1684+
}
1685+
]
1686+
)
1687+
1688+
_, formatted = _format_messages_anthropic([message], project="test-project")
1689+
1690+
assert formatted[0]["content"] == [
1691+
{
1692+
"type": "document",
1693+
"source": {
1694+
"type": "base64",
1695+
"media_type": "application/pdf",
1696+
"data": "cGRm",
1697+
},
1698+
"title": "Report",
1699+
"cache_control": {"type": "ephemeral"},
1700+
}
1701+
]
1702+
1703+
1704+
@pytest.mark.parametrize(
1705+
("block", "expected_source"),
1706+
[
1707+
(
1708+
{"type": "file", "url": "https://example.com/report.pdf"},
1709+
{"type": "url", "url": "https://example.com/report.pdf"},
1710+
),
1711+
(
1712+
{"type": "file", "file_id": "file_123"},
1713+
{"type": "file", "file_id": "file_123"},
1714+
),
1715+
(
1716+
{"type": "file", "source_type": "id", "id": "file_123"},
1717+
{"type": "file", "file_id": "file_123"},
1718+
),
1719+
(
1720+
{"type": "file", "source_type": "text", "text": "notes"},
1721+
{"type": "text", "media_type": "text/plain", "data": "notes"},
1722+
),
1723+
(
1724+
{"type": "text-plain", "text": "notes", "mime_type": "text/markdown"},
1725+
{"type": "text", "media_type": "text/markdown", "data": "notes"},
1726+
),
1727+
],
1728+
)
1729+
def test_format_messages_document_sources(
1730+
block: dict[str, Any], expected_source: dict[str, Any]
1731+
) -> None:
1732+
message = HumanMessage(content=[block])
1733+
1734+
_, formatted = _format_messages_anthropic([message], project="test-project")
1735+
1736+
assert formatted[0]["content"] == [{"type": "document", "source": expected_source}]
1737+
1738+
1739+
def test_format_messages_file_without_data_raises() -> None:
1740+
message = HumanMessage(
1741+
content=[{"type": "file", "id": "lc-file-id", "mime_type": "application/pdf"}]
1742+
)
1743+
1744+
with pytest.raises(ValueError, match="file content blocks"):
1745+
_format_messages_anthropic([message], project="test-project")
1746+
1747+
15851748
def test_format_messages_anthropic_system_not_first() -> None:
15861749
"""Test that system messages are accepted when not at position 0.
15871750

0 commit comments

Comments
 (0)