diff --git a/src/anthropic/_client.py b/src/anthropic/_client.py index 76b840608..3f527902c 100644 --- a/src/anthropic/_client.py +++ b/src/anthropic/_client.py @@ -211,8 +211,8 @@ def __init__( or profile is not None ) if not has_explicit_credential: - api_key = os.environ.get("ANTHROPIC_API_KEY") - auth_token = os.environ.get("ANTHROPIC_AUTH_TOKEN") + api_key = os.environ.get("ANTHROPIC_API_KEY") or None + auth_token = os.environ.get("ANTHROPIC_AUTH_TOKEN") or None self.api_key = api_key self.auth_token = auth_token # --- end credentials support --- @@ -628,8 +628,8 @@ def __init__( or profile is not None ) if not has_explicit_credential: - api_key = os.environ.get("ANTHROPIC_API_KEY") - auth_token = os.environ.get("ANTHROPIC_AUTH_TOKEN") + api_key = os.environ.get("ANTHROPIC_API_KEY") or None + auth_token = os.environ.get("ANTHROPIC_AUTH_TOKEN") or None self.api_key = api_key self.auth_token = auth_token # --- end credentials support --- diff --git a/src/anthropic/lib/tools/agent_toolset.py b/src/anthropic/lib/tools/agent_toolset.py index 2ccbfb1cc..38e802d7e 100644 --- a/src/anthropic/lib/tools/agent_toolset.py +++ b/src/anthropic/lib/tools/agent_toolset.py @@ -36,6 +36,7 @@ import os import re import uuid +import base64 import shutil import logging import subprocess @@ -86,12 +87,31 @@ BASH_DEFAULT_TIMEOUT = 120.0 DEFAULT_MAX_FILE_BYTES = 256 * 1024 READ_MAX_BYTES = DEFAULT_MAX_FILE_BYTES # For backwards compat only. +# Binary files (images, PDFs) are exempt from the text size cap but get their +# own limit so a multi-MB image doesn't balloon the session context. +DEFAULT_MAX_BINARY_BYTES = 5 * 1024 * 1024 GREP_OUTPUT_LIMIT = 100 * 1024 GREP_MAX_LINE_LENGTH = 2000 GLOB_RESULT_LIMIT = 200 WALK_MAX_ENTRIES = 50_000 _ANSI = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]") +# Map file suffixes to (media_type, block_type) for binary read support. +# "image" blocks are used for image formats; "document" for PDFs. +_BINARY_MEDIA_TYPES: dict[str, tuple[str, str]] = { + ".jpg": ("image/jpeg", "image"), + ".jpeg": ("image/jpeg", "image"), + ".png": ("image/png", "image"), + ".gif": ("image/gif", "image"), + ".webp": ("image/webp", "image"), + ".pdf": ("application/pdf", "document"), +} + + +def _binary_media_type(path: Path) -> tuple[str, str] | None: + """Return (media_type, block_type) if *path* is a known binary format, else None.""" + return _BINARY_MEDIA_TYPES.get(path.suffix.lower()) + def _resolve_max_bytes(configured: int | None | NotGiven) -> int | None: """Resolve a configured cap to an effective size limit. @@ -490,7 +510,7 @@ async def bash( def beta_read_tool(ctx: AgentToolContext) -> BetaAsyncFunctionTool[Any]: @beta_async_tool(name="read", input_schema=BetaManagedAgentsAgentToolset20260401ReadInput) - async def read(file_path: str, view_range: Optional[List[int]] = None) -> str: + async def read(file_path: str, view_range: Optional[List[int]] = None) -> Any: """Read a file rooted at the working directory.""" try: target = resolve_path(ctx, file_path) @@ -503,6 +523,22 @@ async def read(file_path: str, view_range: Optional[List[int]] = None) -> str: st = target.stat() if not S_ISREG(st.st_mode): raise ToolError(f"read: {file_path}: not a regular file") + + binary = _binary_media_type(target) + if binary is not None: + # Binary file (image or PDF): return a base64 content block. + # view_range doesn't apply to binary content. + if view_range: + raise ToolError("read: view_range is not supported for binary files") + if st.st_size > DEFAULT_MAX_BINARY_BYTES: + raise ToolError( + f"read: {file_path} is {st.st_size} bytes, exceeds " + f"{DEFAULT_MAX_BINARY_BYTES}-byte limit for binary files." + ) + media_type, block_type = binary + data = base64.standard_b64encode(target.read_bytes()).decode("ascii") + return [{"type": block_type, "source": {"type": "base64", "media_type": media_type, "data": data}}] + limit = _resolve_max_bytes(ctx.max_file_bytes) if limit is not None and st.st_size > limit: raise ToolError( diff --git a/tests/lib/tools/test_agent_toolset.py b/tests/lib/tools/test_agent_toolset.py index a4ebfd435..c8dd1101f 100644 --- a/tests/lib/tools/test_agent_toolset.py +++ b/tests/lib/tools/test_agent_toolset.py @@ -2,6 +2,7 @@ import os import sys +import base64 from pathlib import Path import anyio @@ -207,6 +208,55 @@ async def test_read_rejects_directory_even_when_uncapped(tmp_path: Path) -> None await beta_read_tool(env).call({"file_path": "sub"}) +@pytest.mark.parametrize( + ("filename", "magic", "expected_media_type", "expected_block_type"), + [ + ("img.jpg", b"\xff\xd8\xff\xe0" + b"\x00" * 16, "image/jpeg", "image"), + ("img.jpeg", b"\xff\xd8\xff\xe0" + b"\x00" * 16, "image/jpeg", "image"), + ("img.png", b"\x89PNG\r\n\x1a\n" + b"\x00" * 12, "image/png", "image"), + ("img.gif", b"GIF89a" + b"\x00" * 14, "image/gif", "image"), + ("img.webp", b"RIFF" + b"\x00" * 4 + b"WEBP", "image/webp", "image"), + ("doc.pdf", b"%PDF-1.4" + b"\x00" * 12, "application/pdf", "document"), + ], +) +@needs_pydantic_v2 +async def test_read_binary_returns_content_block( + tmp_path: Path, + filename: str, + magic: bytes, + expected_media_type: str, + expected_block_type: str, +) -> None: + payload = magic + b"\x00" * 32 + (tmp_path / filename).write_bytes(payload) + env = AgentToolContext(workdir=str(tmp_path)) + result = await beta_read_tool(env).call({"file_path": filename}) + assert isinstance(result, list) and len(result) == 1 + block = result[0] + assert block["type"] == expected_block_type + assert block["source"]["type"] == "base64" + assert block["source"]["media_type"] == expected_media_type + assert base64.standard_b64decode(block["source"]["data"]) == payload + + +@needs_pydantic_v2 +async def test_read_binary_rejects_view_range(tmp_path: Path) -> None: + (tmp_path / "img.png").write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 32) + env = AgentToolContext(workdir=str(tmp_path)) + with pytest.raises(ToolError, match="view_range is not supported for binary files"): + await beta_read_tool(env).call({"file_path": "img.png", "view_range": [1, 2]}) + + +@needs_pydantic_v2 +async def test_read_binary_rejects_oversized(tmp_path: Path) -> None: + from anthropic.lib.tools.agent_toolset import DEFAULT_MAX_BINARY_BYTES + + (tmp_path / "big.png").write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * DEFAULT_MAX_BINARY_BYTES) + env = AgentToolContext(workdir=str(tmp_path)) + with pytest.raises(ToolError, match="exceeds"): + await beta_read_tool(env).call({"file_path": "big.png"}) + + @pytest.mark.parametrize( ("description", "args", "want_error"), [