mirror of
https://github.com/langchain-ai/langchain.git
synced 2026-10-05 09:25:14 +03:00
feat(anthropic): support top-level param for skills via container; updates thinking display mode (#39962)
This commit is contained in:
1 parent
c253f54b90
commit
e3f6adfe19
4 files changed
+234
-13
No files matched your search
@@ -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"
|
||||
|
||||
Binary file not shown.
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user