diff --git a/libs/partners/anthropic/langchain_anthropic/chat_models.py b/libs/partners/anthropic/langchain_anthropic/chat_models.py index bad2789fb1..10298786b2 100644 --- a/libs/partners/anthropic/langchain_anthropic/chat_models.py +++ b/libs/partners/anthropic/langchain_anthropic/chat_models.py @@ -177,7 +177,6 @@ class AnthropicTool(TypedDict): _TOOL_TYPE_TO_BETA: dict[str, str] = { "web_fetch_20250910": "web-fetch-2025-09-10", "code_execution_20250522": "code-execution-2025-05-22", - "code_execution_20250825": "code-execution-2025-08-25", "mcp_toolset": "mcp-client-2025-11-20", "memory_20250818": "context-management-2025-06-27", "computer_20250124": "computer-use-2025-01-24", @@ -816,6 +815,15 @@ def _format_messages( return system, formatted_messages +def _container_id(container: Any) -> str | None: + """Return the container ID from either accepted `container` shape.""" + if isinstance(container, str): + return container + if isinstance(container, dict): + return container.get("id") + return None + + def _collect_code_execution_tool_ids(formatted_messages: list[dict]) -> set[str]: """Collect `tool_use` IDs that were called by `code_execution`. @@ -1255,6 +1263,28 @@ class ChatAnthropic(BaseChatModel): [context management](https://platform.claude.com/docs/en/build-with-claude/context-editing). """ + container: dict[str, Any] | str | None = None + """Code execution container for the request. + + Either a container ID from a previous response, or a dict of container + parameters — notably + [skills](https://platform.claude.com/docs/en/build-with-claude/skills-guide) + to load into the container. Skills require a + [code execution](https://docs.langchain.com/oss/python/integrations/chat/anthropic#code-execution) + tool to be bound. + + ```python + model = ChatAnthropic( + model="claude-opus-5", + container={ + "skills": [{"type": "anthropic", "skill_id": "pptx", "version": "latest"}] + }, + ).bind_tools([{"type": "code_execution_20260521", "name": "code_execution"}]) + ``` + + Can also be passed at call time, which overrides the value set here. + """ + reuse_last_container: bool | None = None """Automatically reuse container from most recent response (code execution). @@ -1582,6 +1612,7 @@ class ChatAnthropic(BaseChatModel): "betas": self.betas, "context_management": self.context_management, "mcp_servers": self.mcp_servers, + "container": self.container, "user_profile_id": self.user_profile_id, "system": system, **self.model_kwargs, @@ -1667,19 +1698,43 @@ class ChatAnthropic(BaseChatModel): output_config = payload.setdefault("output_config", {}) output_config["format"] = payload.pop("output_format") - if self.reuse_last_container: - # Check for most recent AIMessage with container set in response_metadata - # and set as a top-level param on the request + container = payload.get("container") + + if self.reuse_last_container and not _container_id(container): + # Reuse the container from the most recent response (code execution) for message in reversed(messages): if ( isinstance(message, AIMessage) - and (container := message.response_metadata.get("container")) - and isinstance(container, dict) - and (container_id := container.get("id")) + and isinstance( + last_container := message.response_metadata.get("container"), + dict, + ) + and (container_id := last_container.get("id")) ): - payload["container"] = container_id + payload["container"] = ( + {**container, "id": container_id} + if isinstance(container, dict) + else container_id + ) break + if ( + isinstance(container, dict) + and container.get("skills") + and not any( + isinstance(tool, dict) + and str(tool.get("type", "")).startswith("code_execution") + for tool in (payload.get("tools") or []) + ) + ): + warnings.warn( + "Skills require a code execution tool to be bound, e.g. " + '`bind_tools([{"type": "code_execution_20260521", ' + '"name": "code_execution"}])`.', + UserWarning, + stacklevel=2, + ) + # Note: Beta headers are no longer required for structured outputs # (output_config.format or strict tool use) as they are now generally available if "tools" in payload and isinstance(payload["tools"], list): @@ -1731,6 +1786,16 @@ class ChatAnthropic(BaseChatModel): else: payload["betas"] = [required_beta] + # Auto-append required beta for the `updates` thinking display mode + thinking = payload.get("thinking") + if isinstance(thinking, dict) and thinking.get("display") == "updates": + required_beta = "thinking-display-updates-2026-08-18" + if payload.get("betas"): + if required_beta not in payload["betas"]: + payload["betas"] = [*payload["betas"], required_beta] + else: + payload["betas"] = [required_beta] + # Auto-append required beta for user_profile_id if payload.get("user_profile_id"): required_beta = "user-profiles-2026-03-24" diff --git a/libs/partners/anthropic/tests/cassettes/test_skills.yaml.gz b/libs/partners/anthropic/tests/cassettes/test_skills.yaml.gz new file mode 100644 index 0000000000..a9ac3920cf Binary files /dev/null and b/libs/partners/anthropic/tests/cassettes/test_skills.yaml.gz differ 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 905c8a3064..7c487c45bf 100644 --- a/libs/partners/anthropic/tests/integration_tests/test_chat_models.py +++ b/libs/partners/anthropic/tests/integration_tests/test_chat_models.py @@ -1884,17 +1884,22 @@ def test_code_execution_old(output_version: Literal["v0", "v1"]) -> None: ) +def _collect_file_ids(content: Any) -> list[str]: + """Recursively collect `file_id` values from response content.""" + if isinstance(content, dict): + found = [content["file_id"]] if "file_id" in content else [] + return found + [fid for v in content.values() for fid in _collect_file_ids(v)] + if isinstance(content, list): + return [fid for item in content for fid in _collect_file_ids(item)] + return [] + + @pytest.mark.default_cassette("test_code_execution.yaml.gz") @pytest.mark.vcr @pytest.mark.parametrize("output_version", ["v0", "v1"]) def test_code_execution(output_version: Literal["v0", "v1"]) -> None: - """Note: this is a beta feature. - - TODO: Update to remove beta once generally available. - """ llm = ChatAnthropic( model=MODEL_NAME, # type: ignore[call-arg] - betas=["code-execution-2025-08-25"], output_version=output_version, ) @@ -1952,6 +1957,61 @@ def test_code_execution(output_version: Literal["v0", "v1"]) -> None: ) +@pytest.mark.default_cassette("test_skills.yaml.gz") +@pytest.mark.vcr +@pytest.mark.parametrize("output_version", ["v0", "v1"]) +def test_skills(output_version: Literal["v0", "v1"]) -> None: + """Load an Anthropic skill into the code execution container.""" + skills = [{"type": "anthropic", "skill_id": "xlsx"}] + code_execution = {"type": "code_execution_20250825", "name": "code_execution"} + llm = ChatAnthropic( + model=MODEL_NAME, # type: ignore[call-arg] + container={"skills": skills}, + reuse_last_container=True, + output_version=output_version, + ) + llm_with_tools = llm.bind_tools([code_execution]) + + input_message = { + "role": "user", + "content": "Create an xlsx file with a single cell containing the number 42.", + } + + # Stream the first turn. `.output` blocks until the stream finishes and + # returns the aggregated message. + # `stream_events` is typed as `Iterator[Any]` on a bound model; the v3 + # protocol returns a `ChatModelStream`. + stream = cast("Any", llm_with_tools.stream_events([input_message], version="v3")) + first_response = stream.output + + # The skill ran in the container and wrote a file. + container_id = first_response.response_metadata["container"]["id"] + assert container_id + assert _collect_file_ids(first_response.content) + + # `reuse_last_container` supplies the container ID on the next turn without + # dropping the skills. + messages: list = [ + input_message, + first_response, + {"role": "user", "content": "Now change the cell to 43."}, + ] + payload = llm._get_request_payload(messages, tools=[code_execution]) + assert payload["container"] == {"id": container_id, "skills": skills} + + # The aggregated stream is valid history, so the follow-up round-trips. + second_response = llm_with_tools.invoke(messages) + block_types = {block["type"] for block in second_response.content} # type: ignore[index] + if output_version == "v0": + assert { + "text", + "server_tool_use", + "bash_code_execution_tool_result", + } <= block_types + else: + assert {"text", "server_tool_call", "server_tool_result"} <= block_types + + @pytest.mark.default_cassette("test_remote_mcp.yaml.gz") @pytest.mark.vcr @pytest.mark.parametrize("output_version", ["v0", "v1"]) 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 0f2c3802c4..6c40ccf632 100644 --- a/libs/partners/anthropic/tests/unit_tests/test_chat_models.py +++ b/libs/partners/anthropic/tests/unit_tests/test_chat_models.py @@ -4985,3 +4985,99 @@ def test_unrelated_type_error_propagates_unchanged() -> None: llm.invoke([HumanMessage(content="test")]) assert exc_info.value is unrelated_error + + +_CODE_EXECUTION_TOOL = [{"type": "code_execution_20250825", "name": "code_execution"}] +_PPTX_SKILL = [{"type": "anthropic", "skill_id": "pptx", "version": "latest"}] + + +def test_container_init_param() -> None: + """`container` set at construction is included in the payload.""" + llm = ChatAnthropic(model=MODEL_NAME, container={"skills": _PPTX_SKILL}) + payload = llm._get_request_payload( + [HumanMessage("Hello, world!")], tools=_CODE_EXECUTION_TOOL + ) + assert payload["container"] == {"skills": _PPTX_SKILL} + + +def test_container_runtime_overrides_init() -> None: + """A call-time `container` takes precedence over the init value.""" + llm = ChatAnthropic(model=MODEL_NAME, container={"skills": _PPTX_SKILL}) + payload = llm._get_request_payload( + [HumanMessage("Hello, world!")], + tools=_CODE_EXECUTION_TOOL, + container="container_runtime", + ) + assert payload["container"] == "container_runtime" + + +def test_container_merged_with_reused_container() -> None: + """`reuse_last_container` supplies an ID without dropping other keys.""" + llm = ChatAnthropic( + model=MODEL_NAME, container={"skills": _PPTX_SKILL}, reuse_last_container=True + ) + messages = [ + HumanMessage("Hello, world!"), + AIMessage("Done.", response_metadata={"container": {"id": "container_123"}}), + HumanMessage("Now edit it."), + ] + payload = llm._get_request_payload(messages, tools=_CODE_EXECUTION_TOOL) + assert payload["container"] == {"id": "container_123", "skills": _PPTX_SKILL} + + +def test_reuse_last_container_without_container_param() -> None: + """Without a `container`, a reused container is passed as a bare ID.""" + llm = ChatAnthropic(model=MODEL_NAME, reuse_last_container=True) + messages = [ + HumanMessage("Hello, world!"), + AIMessage("Done.", response_metadata={"container": {"id": "container_123"}}), + HumanMessage("Again."), + ] + payload = llm._get_request_payload(messages, tools=_CODE_EXECUTION_TOOL) + assert payload["container"] == "container_123" + + +def test_reuse_last_container_does_not_override_explicit_id() -> None: + """An explicitly passed container ID wins over `reuse_last_container`.""" + llm = ChatAnthropic(model=MODEL_NAME, reuse_last_container=True) + messages = [ + HumanMessage("Hello, world!"), + AIMessage("Done.", response_metadata={"container": {"id": "container_123"}}), + HumanMessage("Again."), + ] + payload = llm._get_request_payload( + messages, tools=_CODE_EXECUTION_TOOL, container="container_explicit" + ) + assert payload["container"] == "container_explicit" + + +def test_container_absent_by_default() -> None: + """When unset, `container` is stripped from the payload.""" + llm = ChatAnthropic(model=MODEL_NAME) + payload = llm._get_request_payload([HumanMessage("Hello, world!")]) + assert "container" not in payload + + +def test_skills_without_code_execution_tool_warns() -> None: + """Skills are inert without a code execution tool, so warn.""" + llm = ChatAnthropic(model=MODEL_NAME, container={"skills": _PPTX_SKILL}) + with pytest.warns(UserWarning, match="code execution tool"): + llm._get_request_payload([HumanMessage("Hello, world!")]) + + +def test_thinking_display_updates_enables_beta() -> None: + """`display="updates"` auto-enables its beta, routing through beta.messages.""" + llm = ChatAnthropic( + model=MODEL_NAME, thinking={"type": "adaptive", "display": "updates"} + ) + payload = llm._get_request_payload([HumanMessage("Hello, world!")]) + assert "thinking-display-updates-2026-08-18" in payload["betas"] + + +def test_thinking_display_summarized_does_not_enable_beta() -> None: + """Other `display` values are generally available.""" + llm = ChatAnthropic( + model=MODEL_NAME, thinking={"type": "adaptive", "display": "summarized"} + ) + payload = llm._get_request_payload([HumanMessage("Hello, world!")]) + assert "betas" not in payload