feat(anthropic): support top-level param for skills via container; updates thinking display mode (#39962)

This commit is contained in:
ccurme authored and GitHub committed 2026-08-27 11:19:28 -04:00
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"
@@ -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