diff --git a/.github/workflows/_integration_test.yml b/.github/workflows/_integration_test.yml index 2a4f3aab48c..e64a4a62dff 100644 --- a/.github/workflows/_integration_test.yml +++ b/.github/workflows/_integration_test.yml @@ -41,6 +41,8 @@ jobs: FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }} GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + ANTHROPIC_FILES_API_IMAGE_ID: ${{ secrets.ANTHROPIC_FILES_API_IMAGE_ID }} + ANTHROPIC_FILES_API_PDF_ID: ${{ secrets.ANTHROPIC_FILES_API_PDF_ID }} AZURE_OPENAI_API_VERSION: ${{ secrets.AZURE_OPENAI_API_VERSION }} AZURE_OPENAI_API_BASE: ${{ secrets.AZURE_OPENAI_API_BASE }} AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }} diff --git a/.github/workflows/_release.yml b/.github/workflows/_release.yml index 6f186379153..2c0cd53716f 100644 --- a/.github/workflows/_release.yml +++ b/.github/workflows/_release.yml @@ -344,6 +344,8 @@ jobs: fail-fast: false # Continue testing other partners if one fails env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + ANTHROPIC_FILES_API_IMAGE_ID: ${{ secrets.ANTHROPIC_FILES_API_IMAGE_ID }} + ANTHROPIC_FILES_API_PDF_ID: ${{ secrets.ANTHROPIC_FILES_API_PDF_ID }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} AZURE_OPENAI_API_VERSION: ${{ secrets.AZURE_OPENAI_API_VERSION }} AZURE_OPENAI_API_BASE: ${{ secrets.AZURE_OPENAI_API_BASE }} diff --git a/.github/workflows/scheduled_test.yml b/.github/workflows/scheduled_test.yml index 0cc622f85a7..1b5b094818b 100644 --- a/.github/workflows/scheduled_test.yml +++ b/.github/workflows/scheduled_test.yml @@ -127,6 +127,8 @@ jobs: env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + ANTHROPIC_FILES_API_IMAGE_ID: ${{ secrets.ANTHROPIC_FILES_API_IMAGE_ID }} + ANTHROPIC_FILES_API_PDF_ID: ${{ secrets.ANTHROPIC_FILES_API_PDF_ID }} AZURE_OPENAI_API_VERSION: ${{ secrets.AZURE_OPENAI_API_VERSION }} AZURE_OPENAI_API_BASE: ${{ secrets.AZURE_OPENAI_API_BASE }} AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }} diff --git a/docs/docs/integrations/chat/anthropic.ipynb b/docs/docs/integrations/chat/anthropic.ipynb index b49379aee6a..cfe88b99ff1 100644 --- a/docs/docs/integrations/chat/anthropic.ipynb +++ b/docs/docs/integrations/chat/anthropic.ipynb @@ -325,6 +325,102 @@ "ai_msg.tool_calls" ] }, + { + "cell_type": "markdown", + "id": "535a16e4-cd5a-479f-b315-37c816ec4387", + "metadata": {}, + "source": [ + "## Multimodal\n", + "\n", + "Claude supports image and PDF inputs as content blocks, both in Anthropic's native format (see docs for [vision](https://docs.anthropic.com/en/docs/build-with-claude/vision#base64-encoded-image-example) and [PDF support](https://docs.anthropic.com/en/docs/build-with-claude/pdf-support)) as well as LangChain's [standard format](/docs/how_to/multimodal_inputs/).\n", + "\n", + "### Files API\n", + "\n", + "Claude also supports interactions with files through its managed [Files API](https://docs.anthropic.com/en/docs/build-with-claude/files). See examples below.\n", + "\n", + "The Files API can also be used to upload files to a container for use with Claude's built-in code-execution tools. See the [code execution](#code-execution) section below, for details.\n", + "\n", + "
\n", + "Images\n", + "\n", + "```python\n", + "# Upload image\n", + "\n", + "import anthropic\n", + "\n", + "client = anthropic.Anthropic()\n", + "file = client.beta.files.upload(\n", + " # Supports image/jpeg, image/png, image/gif, image/webp\n", + " file=(\"image.png\", open(\"/path/to/image.png\", \"rb\"), \"image/png\"),\n", + ")\n", + "image_file_id = file.id\n", + "\n", + "\n", + "# Run inference\n", + "from langchain_anthropic import ChatAnthropic\n", + "\n", + "llm = ChatAnthropic(\n", + " model=\"claude-sonnet-4-20250514\",\n", + " betas=[\"files-api-2025-04-14\"],\n", + ")\n", + "\n", + "input_message = {\n", + " \"role\": \"user\",\n", + " \"content\": [\n", + " {\n", + " \"type\": \"text\",\n", + " \"text\": \"Describe this image.\",\n", + " },\n", + " {\n", + " \"type\": \"image\",\n", + " \"source\": {\n", + " \"type\": \"file\",\n", + " \"file_id\": image_file_id,\n", + " },\n", + " },\n", + " ],\n", + "}\n", + "llm.invoke([input_message])\n", + "```\n", + "\n", + "
\n", + "\n", + "
\n", + "PDFs\n", + "\n", + "```python\n", + "# Upload document\n", + "\n", + "import anthropic\n", + "\n", + "client = anthropic.Anthropic()\n", + "file = client.beta.files.upload(\n", + " file=(\"document.pdf\", open(\"/path/to/document.pdf\", \"rb\"), \"application/pdf\"),\n", + ")\n", + "pdf_file_id = file.id\n", + "\n", + "\n", + "# Run inference\n", + "from langchain_anthropic import ChatAnthropic\n", + "\n", + "llm = ChatAnthropic(\n", + " model=\"claude-sonnet-4-20250514\",\n", + " betas=[\"files-api-2025-04-14\"],\n", + ")\n", + "\n", + "input_message = {\n", + " \"role\": \"user\",\n", + " \"content\": [\n", + " {\"type\": \"text\", \"text\": \"Describe this document.\"},\n", + " {\"type\": \"document\", \"source\": {\"type\": \"file\", \"file_id\": pdf_file_id}}\n", + " ],\n", + "}\n", + "llm.invoke([input_message])\n", + "```\n", + "\n", + "
" + ] + }, { "cell_type": "markdown", "id": "6e36d25c-f358-49e5-aefa-b99fbd3fec6b", @@ -454,6 +550,27 @@ "print(f\"\\nSecond:\\n{usage_2}\")" ] }, + { + "cell_type": "markdown", + "id": "9678656f-1ec4-4bf1-bf62-bbd49eb5c4e7", + "metadata": {}, + "source": [ + ":::tip Extended caching\n", + "\n", + " The cache lifetime is 5 minutes by default. If this is too short, you can apply one hour caching by enabling the `\"extended-cache-ttl-2025-04-11\"` beta header:\n", + "\n", + " ```python\n", + " llm = ChatAnthropic(\n", + " model=\"claude-3-7-sonnet-20250219\",\n", + " # highlight-next-line\n", + " betas=[\"extended-cache-ttl-2025-04-11\"],\n", + " )\n", + " ```\n", + " and specifying `\"cache_control\": {\"type\": \"ephemeral\", \"ttl\": \"1h\"}`.\n", + "\n", + ":::" + ] + }, { "cell_type": "markdown", "id": "141ce9c5-012d-4502-9d61-4a413b5d959a", @@ -953,6 +1070,159 @@ "response = llm_with_tools.invoke(\"How do I update a web app to TypeScript 5.5?\")" ] }, + { + "cell_type": "markdown", + "id": "1478cdc6-2e52-4870-80f9-b4ddf88f2db2", + "metadata": {}, + "source": [ + "### Code execution\n", + "\n", + "Claude can use a [code execution tool](https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/code-execution-tool) to execute Python code in a sandboxed environment.\n", + "\n", + ":::info Code execution is supported since ``langchain-anthropic>=0.3.14``\n", + "\n", + ":::" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "2ce13632-a2da-439f-a429-f66481501630", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_anthropic import ChatAnthropic\n", + "\n", + "llm = ChatAnthropic(\n", + " model=\"claude-sonnet-4-20250514\",\n", + " betas=[\"code-execution-2025-05-22\"],\n", + ")\n", + "\n", + "tool = {\"type\": \"code_execution_20250522\", \"name\": \"code_execution\"}\n", + "llm_with_tools = llm.bind_tools([tool])\n", + "\n", + "response = llm_with_tools.invoke(\n", + " \"Calculate the mean and standard deviation of \" \"[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]\"\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "24076f91-3a3d-4e53-9618-429888197061", + "metadata": {}, + "source": [ + "
\n", + "Use with Files API\n", + "\n", + "Using the Files API, Claude can write code to access files for data analysis and other purposes. See example below:\n", + "\n", + "```python\n", + "# Upload file\n", + "\n", + "import anthropic\n", + "\n", + "client = anthropic.Anthropic()\n", + "file = client.beta.files.upload(\n", + " file=open(\"/path/to/sample_data.csv\", \"rb\")\n", + ")\n", + "file_id = file.id\n", + "\n", + "\n", + "# Run inference\n", + "from langchain_anthropic import ChatAnthropic\n", + "\n", + "llm = ChatAnthropic(\n", + " model=\"claude-sonnet-4-20250514\",\n", + " betas=[\"code-execution-2025-05-22\"],\n", + ")\n", + "\n", + "tool = {\"type\": \"code_execution_20250522\", \"name\": \"code_execution\"}\n", + "llm_with_tools = llm.bind_tools([tool])\n", + "\n", + "input_message = {\n", + " \"role\": \"user\",\n", + " \"content\": [\n", + " {\n", + " \"type\": \"text\",\n", + " \"text\": \"Please plot these data and tell me what you see.\",\n", + " },\n", + " {\n", + " \"type\": \"container_upload\",\n", + " \"file_id\": file_id,\n", + " },\n", + " ]\n", + "}\n", + "llm_with_tools.invoke([input_message])\n", + "```\n", + "\n", + "Note that Claude may generate files as part of its code execution. You can access these files using the Files API:\n", + "```python\n", + "# Take all file outputs for demonstration purposes\n", + "file_ids = []\n", + "for block in response.content:\n", + " if block[\"type\"] == \"code_execution_tool_result\":\n", + " file_ids.extend(\n", + " content[\"file_id\"]\n", + " for content in block.get(\"content\", {}).get(\"content\", [])\n", + " if \"file_id\" in content\n", + " )\n", + "\n", + "for i, file_id in enumerate(file_ids):\n", + " file_content = client.beta.files.download(file_id)\n", + " file_content.write_to_file(f\"/path/to/file_{i}.png\")\n", + "```\n", + "\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "040f381a-1768-479a-9a5e-aa2d7d77e0d5", + "metadata": {}, + "source": [ + "### Remote MCP\n", + "\n", + "Claude can use a [MCP connector tool](https://docs.anthropic.com/en/docs/agents-and-tools/mcp-connector) for model-generated calls to remote MCP servers.\n", + "\n", + ":::info Remote MCP is supported since ``langchain-anthropic>=0.3.14``\n", + "\n", + ":::" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "22fc4a89-e6d8-4615-96cb-2e117349aebf", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_anthropic import ChatAnthropic\n", + "\n", + "mcp_servers = [\n", + " {\n", + " \"type\": \"url\",\n", + " \"url\": \"https://mcp.deepwiki.com/mcp\",\n", + " \"name\": \"deepwiki\",\n", + " \"tool_configuration\": { # optional configuration\n", + " \"enabled\": True,\n", + " \"allowed_tools\": [\"ask_question\"],\n", + " },\n", + " \"authorization_token\": \"PLACEHOLDER\", # optional authorization\n", + " }\n", + "]\n", + "\n", + "llm = ChatAnthropic(\n", + " model=\"claude-sonnet-4-20250514\",\n", + " betas=[\"mcp-client-2025-04-04\"],\n", + " mcp_servers=mcp_servers,\n", + ")\n", + "\n", + "response = llm.invoke(\n", + " \"What transport protocols does the 2025-03-26 version of the MCP \"\n", + " \"spec (modelcontextprotocol/modelcontextprotocol) support?\"\n", + ")" + ] + }, { "cell_type": "markdown", "id": "2fd5d545-a40d-42b1-ad0c-0a79e2536c9b", diff --git a/libs/core/langchain_core/language_models/chat_models.py b/libs/core/langchain_core/language_models/chat_models.py index a0e975271f6..1070a8877e4 100644 --- a/libs/core/langchain_core/language_models/chat_models.py +++ b/libs/core/langchain_core/language_models/chat_models.py @@ -129,6 +129,7 @@ def _format_for_tracing(messages: list[BaseMessage]) -> list[BaseMessage]: isinstance(block, dict) and block.get("type") == "image" and is_data_content_block(block) + and block.get("source_type") != "id" ): if message_to_trace is message: message_to_trace = message.model_copy() diff --git a/libs/langchain/tests/unit_tests/chat_models/test_base.py b/libs/langchain/tests/unit_tests/chat_models/test_base.py index 9c24390de48..e90b7d6dca0 100644 --- a/libs/langchain/tests/unit_tests/chat_models/test_base.py +++ b/libs/langchain/tests/unit_tests/chat_models/test_base.py @@ -193,6 +193,7 @@ def test_configurable_with_default() -> None: "name": None, "disable_streaming": False, "model": "claude-3-sonnet-20240229", + "mcp_servers": None, "max_tokens": 1024, "temperature": None, "thinking": None, @@ -203,6 +204,7 @@ def test_configurable_with_default() -> None: "stop_sequences": None, "anthropic_api_url": "https://api.anthropic.com", "anthropic_api_key": SecretStr("bar"), + "betas": None, "default_headers": None, "model_kwargs": {}, "streaming": False, diff --git a/libs/partners/anthropic/langchain_anthropic/chat_models.py b/libs/partners/anthropic/langchain_anthropic/chat_models.py index a0313988604..3bc75804f08 100644 --- a/libs/partners/anthropic/langchain_anthropic/chat_models.py +++ b/libs/partners/anthropic/langchain_anthropic/chat_models.py @@ -1,4 +1,5 @@ import copy +import json import re import warnings from collections.abc import AsyncIterator, Iterator, Mapping, Sequence @@ -100,6 +101,7 @@ def _is_builtin_tool(tool: Any) -> bool: "computer_", "bash_", "web_search_", + "code_execution_", ] return any(tool_type.startswith(prefix) for prefix in _builtin_tool_prefixes) @@ -219,6 +221,14 @@ def _format_data_content_block(block: dict) -> dict: "data": block["data"], }, } + elif block["source_type"] == "id": + formatted_block = { + "type": "image", + "source": { + "type": "file", + "file_id": block["id"], + }, + } else: raise ValueError( "Anthropic only supports 'url' and 'base64' source_type for image " @@ -252,6 +262,14 @@ def _format_data_content_block(block: dict) -> dict: "data": block["text"], }, } + elif block["source_type"] == "id": + formatted_block = { + "type": "document", + "source": { + "type": "file", + "file_id": block["id"], + }, + } else: raise ValueError(f"Block of type {block['type']} is not supported.") @@ -340,6 +358,29 @@ def _format_messages( else: block.pop("text", None) content.append(block) + elif block["type"] in ("server_tool_use", "mcp_tool_use"): + formatted_block = { + k: v + for k, v in block.items() + if k + in ( + "type", + "id", + "input", + "name", + "server_name", # for mcp_tool_use + "cache_control", + ) + } + # Attempt to parse streamed output + if block.get("input") == {} and "partial_json" in block: + try: + input_ = json.loads(block["partial_json"]) + if input_: + formatted_block["input"] = input_ + except json.JSONDecodeError: + pass + content.append(formatted_block) elif block["type"] == "text": text = block.get("text", "") # Only add non-empty strings for now as empty ones are not @@ -375,6 +416,25 @@ def _format_messages( [HumanMessage(block["content"])] )[1][0]["content"] content.append({**block, **{"content": tool_content}}) + elif block["type"] in ( + "code_execution_tool_result", + "mcp_tool_result", + "web_search_tool_result", + ): + content.append( + { + k: v + for k, v in block.items() + if k + in ( + "type", + "content", + "tool_use_id", + "is_error", # for mcp_tool_result + "cache_control", + ) + } + ) else: content.append(block) else: @@ -472,20 +532,21 @@ class ChatAnthropic(BaseChatModel): **NOTE**: Any param which is not explicitly supported will be passed directly to the ``anthropic.Anthropic.messages.create(...)`` API every time to the model is invoked. For example: - .. code-block:: python - from langchain_anthropic import ChatAnthropic - import anthropic + .. code-block:: python - ChatAnthropic(..., extra_headers={}).invoke(...) + from langchain_anthropic import ChatAnthropic + import anthropic - # results in underlying API call of: + ChatAnthropic(..., extra_headers={}).invoke(...) - anthropic.Anthropic(..).messages.create(..., extra_headers={}) + # results in underlying API call of: - # which is also equivalent to: + anthropic.Anthropic(..).messages.create(..., extra_headers={}) - ChatAnthropic(...).invoke(..., extra_headers={}) + # which is also equivalent to: + + ChatAnthropic(...).invoke(..., extra_headers={}) Invoke: .. code-block:: python @@ -645,6 +706,35 @@ class ChatAnthropic(BaseChatModel): "After examining both images carefully, I can see that they are actually identical." + .. dropdown:: Files API + + You can also pass in files that are managed through Anthropic's + `Files API `_: + + .. code-block:: python + + from langchain_anthropic import ChatAnthropic + + llm = ChatAnthropic( + model="claude-sonnet-4-20250514", + betas=["files-api-2025-04-14"], + ) + input_message = { + "role": "user", + "content": [ + { + "type": "text", + "text": "Describe this document.", + }, + { + "type": "image", + "source_type": "id", + "id": "file_abc123...", + }, + ], + } + llm.invoke([input_message]) + PDF input: See `multimodal guides `_ for more detail. @@ -681,6 +771,35 @@ class ChatAnthropic(BaseChatModel): "This appears to be a simple document..." + .. dropdown:: Files API + + You can also pass in files that are managed through Anthropic's + `Files API `_: + + .. code-block:: python + + from langchain_anthropic import ChatAnthropic + + llm = ChatAnthropic( + model="claude-sonnet-4-20250514", + betas=["files-api-2025-04-14"], + ) + input_message = { + "role": "user", + "content": [ + { + "type": "text", + "text": "Describe this document.", + }, + { + "type": "file", + "source_type": "id", + "id": "file_abc123...", + }, + ], + } + llm.invoke([input_message]) + Extended thinking: Claude 3.7 Sonnet supports an `extended thinking `_ @@ -797,7 +916,7 @@ class ChatAnthropic(BaseChatModel): or by setting ``stream_usage=False`` when initializing ChatAnthropic. Prompt caching: - See LangChain `docs `_ + See LangChain `docs `_ for more detail. .. code-block:: python @@ -834,6 +953,24 @@ class ChatAnthropic(BaseChatModel): {'cache_read': 0, 'cache_creation': 1458} + .. dropdown:: Extended caching + + The cache lifetime is 5 minutes by default. If this is too short, you can + apply one hour caching by enabling the ``"extended-cache-ttl-2025-04-11"`` + beta header: + + .. code-block:: python + + llm = ChatAnthropic( + model="claude-3-7-sonnet-20250219", + betas=["extended-cache-ttl-2025-04-11"], + ) + + and specifying ``"cache_control": {"type": "ephemeral", "ttl": "1h"}``. + + See `Claude documentation `_ + for detail. + Token-efficient tool use (beta): See LangChain `docs `_ for more detail. @@ -875,7 +1012,7 @@ class ChatAnthropic(BaseChatModel): See LangChain `docs `_ for more detail. - Web search: + .. dropdown:: Web search .. code-block:: python @@ -890,7 +1027,53 @@ class ChatAnthropic(BaseChatModel): "How do I update a web app to TypeScript 5.5?" ) - Text editor: + .. dropdown:: Code execution + + .. code-block:: python + + llm = ChatAnthropic( + model="claude-sonnet-4-20250514", + betas=["code-execution-2025-05-22"], + ) + + tool = {"type": "code_execution_20250522", "name": "code_execution"} + llm_with_tools = llm.bind_tools([tool]) + + response = llm_with_tools.invoke( + "Calculate the mean and standard deviation of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]" + ) + + .. dropdown:: Remote MCP + + .. code-block:: python + + from langchain_anthropic import ChatAnthropic + + mcp_servers = [ + { + "type": "url", + "url": "https://mcp.deepwiki.com/mcp", + "name": "deepwiki", + "tool_configuration": { # optional configuration + "enabled": True, + "allowed_tools": ["ask_question"], + }, + "authorization_token": "PLACEHOLDER", # optional authorization + } + ] + + llm = ChatAnthropic( + model="claude-sonnet-4-20250514", + betas=["mcp-client-2025-04-04"], + mcp_servers=mcp_servers, + ) + + response = llm.invoke( + "What transport protocols does the 2025-03-26 version of the MCP " + "spec (modelcontextprotocol/modelcontextprotocol) support?" + ) + + .. dropdown:: Text editor .. code-block:: python @@ -986,6 +1169,13 @@ class ChatAnthropic(BaseChatModel): default_headers: Optional[Mapping[str, str]] = None """Headers to pass to the Anthropic clients, will be used for every API call.""" + betas: Optional[list[str]] = None + """List of beta features to enable. If specified, invocations will be routed + through client.beta.messages.create. + + Example: ``betas=["mcp-client-2025-04-04"]`` + """ + model_kwargs: dict[str, Any] = Field(default_factory=dict) streaming: bool = False @@ -1000,6 +1190,13 @@ class ChatAnthropic(BaseChatModel): """Parameters for Claude reasoning, e.g., ``{"type": "enabled", "budget_tokens": 10_000}``""" + mcp_servers: Optional[list[dict[str, Any]]] = None + """List of MCP servers to use for the request. + + Example: ``mcp_servers=[{"type": "url", "url": "https://mcp.example.com/mcp", + "name": "example-mcp"}]`` + """ + @property def _llm_type(self) -> str: """Return type of chat model.""" @@ -1007,7 +1204,10 @@ class ChatAnthropic(BaseChatModel): @property def lc_secrets(self) -> dict[str, str]: - return {"anthropic_api_key": "ANTHROPIC_API_KEY"} + return { + "anthropic_api_key": "ANTHROPIC_API_KEY", + "mcp_servers": "ANTHROPIC_MCP_SERVERS", + } @classmethod def is_lc_serializable(cls) -> bool: @@ -1099,6 +1299,8 @@ class ChatAnthropic(BaseChatModel): "top_k": self.top_k, "top_p": self.top_p, "stop_sequences": stop or self.stop_sequences, + "betas": self.betas, + "mcp_servers": self.mcp_servers, "system": system, **self.model_kwargs, **kwargs, @@ -1107,6 +1309,18 @@ class ChatAnthropic(BaseChatModel): payload["thinking"] = self.thinking return {k: v for k, v in payload.items() if v is not None} + def _create(self, payload: dict) -> Any: + if "betas" in payload: + return self._client.beta.messages.create(**payload) + else: + return self._client.messages.create(**payload) + + async def _acreate(self, payload: dict) -> Any: + if "betas" in payload: + return await self._async_client.beta.messages.create(**payload) + else: + return await self._async_client.messages.create(**payload) + def _stream( self, messages: list[BaseMessage], @@ -1121,17 +1335,19 @@ class ChatAnthropic(BaseChatModel): kwargs["stream"] = True payload = self._get_request_payload(messages, stop=stop, **kwargs) try: - stream = self._client.messages.create(**payload) + stream = self._create(payload) coerce_content_to_string = ( not _tools_in_params(payload) and not _documents_in_params(payload) and not _thinking_in_params(payload) ) + block_start_event = None for event in stream: - msg = _make_message_chunk_from_anthropic_event( + msg, block_start_event = _make_message_chunk_from_anthropic_event( event, stream_usage=stream_usage, coerce_content_to_string=coerce_content_to_string, + block_start_event=block_start_event, ) if msg is not None: chunk = ChatGenerationChunk(message=msg) @@ -1155,17 +1371,19 @@ class ChatAnthropic(BaseChatModel): kwargs["stream"] = True payload = self._get_request_payload(messages, stop=stop, **kwargs) try: - stream = await self._async_client.messages.create(**payload) + stream = await self._acreate(payload) coerce_content_to_string = ( not _tools_in_params(payload) and not _documents_in_params(payload) and not _thinking_in_params(payload) ) + block_start_event = None async for event in stream: - msg = _make_message_chunk_from_anthropic_event( + msg, block_start_event = _make_message_chunk_from_anthropic_event( event, stream_usage=stream_usage, coerce_content_to_string=coerce_content_to_string, + block_start_event=block_start_event, ) if msg is not None: chunk = ChatGenerationChunk(message=msg) @@ -1234,7 +1452,7 @@ class ChatAnthropic(BaseChatModel): return generate_from_stream(stream_iter) payload = self._get_request_payload(messages, stop=stop, **kwargs) try: - data = self._client.messages.create(**payload) + data = self._create(payload) except anthropic.BadRequestError as e: _handle_anthropic_bad_request(e) return self._format_output(data, **kwargs) @@ -1253,7 +1471,7 @@ class ChatAnthropic(BaseChatModel): return await agenerate_from_stream(stream_iter) payload = self._get_request_payload(messages, stop=stop, **kwargs) try: - data = await self._async_client.messages.create(**payload) + data = await self._acreate(payload) except anthropic.BadRequestError as e: _handle_anthropic_bad_request(e) return self._format_output(data, **kwargs) @@ -1722,8 +1940,10 @@ def convert_to_anthropic_tool( def _tools_in_params(params: dict) -> bool: - return "tools" in params or ( - "extra_body" in params and params["extra_body"].get("tools") + return ( + "tools" in params + or ("extra_body" in params and params["extra_body"].get("tools")) + or "mcp_servers" in params ) @@ -1772,7 +1992,8 @@ def _make_message_chunk_from_anthropic_event( *, stream_usage: bool = True, coerce_content_to_string: bool, -) -> Optional[AIMessageChunk]: + block_start_event: Optional[anthropic.types.RawMessageStreamEvent] = None, +) -> tuple[Optional[AIMessageChunk], Optional[anthropic.types.RawMessageStreamEvent]]: """Convert Anthropic event to AIMessageChunk. Note that not all events will result in a message chunk. In these cases @@ -1800,7 +2021,17 @@ def _make_message_chunk_from_anthropic_event( elif ( event.type == "content_block_start" and event.content_block is not None - and event.content_block.type in ("tool_use", "document", "redacted_thinking") + and event.content_block.type + in ( + "tool_use", + "code_execution_tool_result", + "document", + "redacted_thinking", + "mcp_tool_use", + "mcp_tool_result", + "server_tool_use", + "web_search_tool_result", + ) ): if coerce_content_to_string: warnings.warn("Received unexpected tool content block.") @@ -1820,6 +2051,7 @@ def _make_message_chunk_from_anthropic_event( content=[content_block], tool_call_chunks=tool_call_chunks, # type: ignore ) + block_start_event = event elif event.type == "content_block_delta": if event.delta.type in ("text_delta", "citations_delta"): if coerce_content_to_string and hasattr(event.delta, "text"): @@ -1849,16 +2081,23 @@ def _make_message_chunk_from_anthropic_event( elif event.delta.type == "input_json_delta": content_block = event.delta.model_dump() content_block["index"] = event.index - content_block["type"] = "tool_use" - tool_call_chunk = create_tool_call_chunk( - index=event.index, - id=None, - name=None, - args=event.delta.partial_json, - ) + if ( + (block_start_event is not None) + and hasattr(block_start_event, "content_block") + and (block_start_event.content_block.type == "tool_use") + ): + tool_call_chunk = create_tool_call_chunk( + index=event.index, + id=None, + name=None, + args=event.delta.partial_json, + ) + tool_call_chunks = [tool_call_chunk] + else: + tool_call_chunks = [] message_chunk = AIMessageChunk( content=[content_block], - tool_call_chunks=[tool_call_chunk], # type: ignore + tool_call_chunks=tool_call_chunks, # type: ignore ) elif event.type == "message_delta" and stream_usage: usage_metadata = UsageMetadata( @@ -1877,7 +2116,7 @@ def _make_message_chunk_from_anthropic_event( else: pass - return message_chunk + return message_chunk, block_start_event @deprecated(since="0.1.0", removal="1.0.0", alternative="ChatAnthropic") diff --git a/libs/partners/anthropic/pyproject.toml b/libs/partners/anthropic/pyproject.toml index 2177a569b26..02255079587 100644 --- a/libs/partners/anthropic/pyproject.toml +++ b/libs/partners/anthropic/pyproject.toml @@ -7,7 +7,7 @@ authors = [] license = { text = "MIT" } requires-python = ">=3.9" dependencies = [ - "anthropic<1,>=0.51.0", + "anthropic<1,>=0.52.0", "langchain-core<1.0.0,>=0.3.59", "pydantic<3.0.0,>=2.7.4", ] diff --git a/libs/partners/anthropic/tests/integration_tests/test_chat_models.py b/libs/partners/anthropic/tests/integration_tests/test_chat_models.py index a2563cc480d..1fe50cf91d7 100644 --- a/libs/partners/anthropic/tests/integration_tests/test_chat_models.py +++ b/libs/partners/anthropic/tests/integration_tests/test_chat_models.py @@ -1,6 +1,7 @@ """Test ChatAnthropic chat model.""" import json +import os from base64 import b64encode from typing import Optional @@ -863,3 +864,206 @@ def test_image_tool_calling() -> None: ] llm = ChatAnthropic(model="claude-3-5-sonnet-latest") llm.bind_tools([color_picker]).invoke(messages) + + +# TODO: set up VCR +def test_web_search() -> None: + pytest.skip() + llm = ChatAnthropic(model="claude-3-5-sonnet-latest") + + tool = {"type": "web_search_20250305", "name": "web_search", "max_uses": 1} + llm_with_tools = llm.bind_tools([tool]) + + input_message = { + "role": "user", + "content": [ + { + "type": "text", + "text": "How do I update a web app to TypeScript 5.5?", + } + ], + } + response = llm_with_tools.invoke([input_message]) + block_types = {block["type"] for block in response.content} + assert block_types == {"text", "server_tool_use", "web_search_tool_result"} + + # Test streaming + full: Optional[BaseMessageChunk] = None + for chunk in llm_with_tools.stream([input_message]): + assert isinstance(chunk, AIMessageChunk) + full = chunk if full is None else full + chunk + assert isinstance(full, AIMessageChunk) + assert isinstance(full.content, list) + block_types = {block["type"] for block in full.content} # type: ignore[index] + assert block_types == {"text", "server_tool_use", "web_search_tool_result"} + + # Test we can pass back in + next_message = { + "role": "user", + "content": "Please repeat the last search, but focus on sources from 2024.", + } + _ = llm_with_tools.invoke( + [input_message, full, next_message], + ) + + +def test_code_execution() -> None: + pytest.skip() + llm = ChatAnthropic( + model="claude-sonnet-4-20250514", + betas=["code-execution-2025-05-22"], + ) + + tool = {"type": "code_execution_20250522", "name": "code_execution"} + llm_with_tools = llm.bind_tools([tool]) + + input_message = { + "role": "user", + "content": [ + { + "type": "text", + "text": ( + "Calculate the mean and standard deviation of " + "[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]" + ), + } + ], + } + response = llm_with_tools.invoke([input_message]) + block_types = {block["type"] for block in response.content} + assert block_types == {"text", "server_tool_use", "code_execution_tool_result"} + + # Test streaming + full: Optional[BaseMessageChunk] = None + for chunk in llm_with_tools.stream([input_message]): + assert isinstance(chunk, AIMessageChunk) + full = chunk if full is None else full + chunk + assert isinstance(full, AIMessageChunk) + assert isinstance(full.content, list) + block_types = {block["type"] for block in full.content} # type: ignore[index] + assert block_types == {"text", "server_tool_use", "code_execution_tool_result"} + + # Test we can pass back in + next_message = { + "role": "user", + "content": "Please add more comments to the code.", + } + _ = llm_with_tools.invoke( + [input_message, full, next_message], + ) + + +def test_remote_mcp() -> None: + pytest.skip() + mcp_servers = [ + { + "type": "url", + "url": "https://mcp.deepwiki.com/mcp", + "name": "deepwiki", + "tool_configuration": {"enabled": True, "allowed_tools": ["ask_question"]}, + "authorization_token": "PLACEHOLDER", + } + ] + + llm = ChatAnthropic( + model="claude-sonnet-4-20250514", + betas=["mcp-client-2025-04-04"], + mcp_servers=mcp_servers, + ) + + input_message = { + "role": "user", + "content": [ + { + "type": "text", + "text": ( + "What transport protocols does the 2025-03-26 version of the MCP " + "spec (modelcontextprotocol/modelcontextprotocol) support?" + ), + } + ], + } + response = llm.invoke([input_message]) + block_types = {block["type"] for block in response.content} + assert block_types == {"text", "mcp_tool_use", "mcp_tool_result"} + + # Test streaming + full: Optional[BaseMessageChunk] = None + for chunk in llm.stream([input_message]): + assert isinstance(chunk, AIMessageChunk) + full = chunk if full is None else full + chunk + assert isinstance(full, AIMessageChunk) + assert isinstance(full.content, list) + block_types = {block["type"] for block in full.content} + assert block_types == {"text", "mcp_tool_use", "mcp_tool_result"} + + # Test we can pass back in + next_message = { + "role": "user", + "content": "Please query the same tool again, but add 'please' to your query.", + } + _ = llm.invoke( + [input_message, full, next_message], + ) + + +@pytest.mark.parametrize("block_format", ["anthropic", "standard"]) +def test_files_api_image(block_format: str) -> None: + image_file_id = os.getenv("ANTHROPIC_FILES_API_IMAGE_ID") + if not image_file_id: + pytest.skip() + llm = ChatAnthropic( + model="claude-sonnet-4-20250514", + betas=["files-api-2025-04-14"], + ) + if block_format == "anthropic": + block = { + "type": "image", + "source": { + "type": "file", + "file_id": image_file_id, + }, + } + else: + # standard block format + block = { + "type": "image", + "source_type": "id", + "id": image_file_id, + } + input_message = { + "role": "user", + "content": [ + {"type": "text", "text": "Describe this image."}, + block, + ], + } + _ = llm.invoke([input_message]) + + +@pytest.mark.parametrize("block_format", ["anthropic", "standard"]) +def test_files_api_pdf(block_format: str) -> None: + pdf_file_id = os.getenv("ANTHROPIC_FILES_API_PDF_ID") + if not pdf_file_id: + pytest.skip() + llm = ChatAnthropic( + model="claude-sonnet-4-20250514", + betas=["files-api-2025-04-14"], + ) + if block_format == "anthropic": + block = {"type": "document", "source": {"type": "file", "file_id": pdf_file_id}} + else: + # standard block format + block = { + "type": "file", + "source_type": "id", + "id": pdf_file_id, + } + input_message = { + "role": "user", + "content": [ + {"type": "text", "text": "Describe this document."}, + block, + ], + } + _ = llm.invoke([input_message]) diff --git a/libs/partners/anthropic/tests/unit_tests/test_chat_models.py b/libs/partners/anthropic/tests/unit_tests/test_chat_models.py index 1c18fc08d94..2d418b6bddd 100644 --- a/libs/partners/anthropic/tests/unit_tests/test_chat_models.py +++ b/libs/partners/anthropic/tests/unit_tests/test_chat_models.py @@ -2,7 +2,7 @@ import os from typing import Any, Callable, Literal, Optional, cast -from unittest.mock import patch +from unittest.mock import MagicMock, patch import anthropic import pytest @@ -10,6 +10,8 @@ from anthropic.types import Message, TextBlock, Usage from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage from langchain_core.runnables import RunnableBinding from langchain_core.tools import BaseTool +from langchain_core.tracers.base import BaseTracer +from langchain_core.tracers.schemas import Run from pydantic import BaseModel, Field, SecretStr from pytest import CaptureFixture, MonkeyPatch @@ -994,3 +996,63 @@ def test_usage_metadata_standardization() -> None: assert result["input_tokens"] == 0 assert result["output_tokens"] == 0 assert result["total_tokens"] == 0 + + +class FakeTracer(BaseTracer): + def __init__(self) -> None: + super().__init__() + self.chat_model_start_inputs: list = [] + + def _persist_run(self, run: Run) -> None: + """Persist a run.""" + pass + + def on_chat_model_start(self, *args: Any, **kwargs: Any) -> Run: + self.chat_model_start_inputs.append({"args": args, "kwargs": kwargs}) + return super().on_chat_model_start(*args, **kwargs) + + +def test_mcp_tracing() -> None: + # Test we exclude sensitive information from traces + mcp_servers = [ + { + "type": "url", + "url": "https://mcp.deepwiki.com/mcp", + "name": "deepwiki", + "authorization_token": "PLACEHOLDER", + } + ] + + llm = ChatAnthropic( + model="claude-sonnet-4-20250514", + betas=["mcp-client-2025-04-04"], + mcp_servers=mcp_servers, + ) + + tracer = FakeTracer() + mock_client = MagicMock() + + def mock_create(*args: Any, **kwargs: Any) -> Message: + return Message( + id="foo", + content=[TextBlock(type="text", text="bar")], + model="baz", + role="assistant", + stop_reason=None, + stop_sequence=None, + usage=Usage(input_tokens=2, output_tokens=1), + type="message", + ) + + mock_client.messages.create = mock_create + input_message = HumanMessage("Test query") + with patch.object(llm, "_client", mock_client): + _ = llm.invoke([input_message], config={"callbacks": [tracer]}) + + # Test headers are not traced + assert len(tracer.chat_model_start_inputs) == 1 + assert "PLACEHOLDER" not in str(tracer.chat_model_start_inputs) + + # Test headers are correctly propagated to request + payload = llm._get_request_payload([input_message]) + assert payload["mcp_servers"][0]["authorization_token"] == "PLACEHOLDER" diff --git a/libs/partners/anthropic/uv.lock b/libs/partners/anthropic/uv.lock index 4b18119da96..df8c8056ebf 100644 --- a/libs/partners/anthropic/uv.lock +++ b/libs/partners/anthropic/uv.lock @@ -18,7 +18,7 @@ wheels = [ [[package]] name = "anthropic" -version = "0.51.0" +version = "0.52.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -29,9 +29,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/63/4a/96f99a61ae299f9e5aa3e765d7342d95ab2e2ba5b69a3ffedb00ef779651/anthropic-0.51.0.tar.gz", hash = "sha256:6f824451277992af079554430d5b2c8ff5bc059cc2c968cdc3f06824437da201", size = 219063 } +sdist = { url = "https://files.pythonhosted.org/packages/57/fd/8a9332f5baf352c272494a9d359863a53385a208954c1a7251a524071930/anthropic-0.52.0.tar.gz", hash = "sha256:f06bc924d7eb85f8a43fe587b875ff58b410d60251b7dc5f1387b322a35bd67b", size = 229372 } wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/6e/9637122c5f007103bd5a259f4250bd8f1533dd2473227670fd10a1457b62/anthropic-0.51.0-py3-none-any.whl", hash = "sha256:b8b47d482c9aa1f81b923555cebb687c2730309a20d01be554730c8302e0f62a", size = 263957 }, + { url = "https://files.pythonhosted.org/packages/a0/43/172c0031654908bbac2a87d356fff4de1b4947a9b14b9658540b69416417/anthropic-0.52.0-py3-none-any.whl", hash = "sha256:c026daa164f0e3bde36ce9cbdd27f5f1419fff03306be1e138726f42e6a7810f", size = 286076 }, ] [[package]] @@ -451,7 +451,7 @@ typing = [ [package.metadata] requires-dist = [ - { name = "anthropic", specifier = ">=0.51.0,<1" }, + { name = "anthropic", specifier = ">=0.52.0,<1" }, { name = "langchain-core", editable = "../../core" }, { name = "pydantic", specifier = ">=2.7.4,<3.0.0" }, ] @@ -486,7 +486,7 @@ typing = [ [[package]] name = "langchain-core" -version = "0.3.59" +version = "0.3.61" source = { editable = "../../core" } dependencies = [ { name = "jsonpatch" }, @@ -501,10 +501,9 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "jsonpatch", specifier = ">=1.33,<2.0" }, - { name = "langsmith", specifier = ">=0.1.125,<0.4" }, + { name = "langsmith", specifier = ">=0.1.126,<0.4" }, { name = "packaging", specifier = ">=23.2,<25" }, - { name = "pydantic", marker = "python_full_version < '3.12.4'", specifier = ">=2.5.2,<3.0.0" }, - { name = "pydantic", marker = "python_full_version >= '3.12.4'", specifier = ">=2.7.4,<3.0.0" }, + { name = "pydantic", specifier = ">=2.7.4" }, { name = "pyyaml", specifier = ">=5.3" }, { name = "tenacity", specifier = ">=8.1.0,!=8.4.0,<10.0.0" }, { name = "typing-extensions", specifier = ">=4.7" },