diff --git a/libs/core/langchain_core/messages/ai.py b/libs/core/langchain_core/messages/ai.py index 213f0f1c22..5dccc334ad 100644 --- a/libs/core/langchain_core/messages/ai.py +++ b/libs/core/langchain_core/messages/ai.py @@ -617,8 +617,7 @@ class AIMessageChunk(AIMessage, BaseMessageChunk): isinstance(block, dict) and block.get("type") in {"server_tool_call", "server_tool_call_chunk"} - and (args_str := block.get("args")) - and isinstance(args_str, str) + and isinstance(args_str := block.get("args") or "{}", str) ): try: args = json.loads(args_str) diff --git a/libs/core/langchain_core/messages/block_translators/anthropic.py b/libs/core/langchain_core/messages/block_translators/anthropic.py index 3f52b1c875..678042f4ea 100644 --- a/libs/core/langchain_core/messages/block_translators/anthropic.py +++ b/libs/core/langchain_core/messages/block_translators/anthropic.py @@ -286,8 +286,9 @@ def _convert_to_v1_from_anthropic(message: AIMessage) -> list[types.ContentBlock args=chunk.get("args"), type="tool_call_chunk", ) - if "caller" in block: - tool_call_chunk["extras"] = {"caller": block["caller"]} + for key in ("caller", "toolset_name"): + if key in block: + tool_call_chunk.setdefault("extras", {})[key] = block[key] index = chunk.get("index") if index is not None: @@ -322,10 +323,9 @@ def _convert_to_v1_from_anthropic(message: AIMessage) -> list[types.ContentBlock } if "index" in block: tool_call_block["index"] = block["index"] - if "caller" in block: - if "extras" not in tool_call_block: - tool_call_block["extras"] = {} - tool_call_block["extras"]["caller"] = block["caller"] + for key in ("caller", "toolset_name"): + if key in block: + tool_call_block.setdefault("extras", {})[key] = block[key] yield tool_call_block diff --git a/libs/core/tests/unit_tests/messages/block_translators/test_anthropic.py b/libs/core/tests/unit_tests/messages/block_translators/test_anthropic.py index e67fb37998..7dc5d0797d 100644 --- a/libs/core/tests/unit_tests/messages/block_translators/test_anthropic.py +++ b/libs/core/tests/unit_tests/messages/block_translators/test_anthropic.py @@ -572,3 +572,34 @@ def test_convert_to_v1_from_anthropic_malformed_citations() -> None: ], }, ] + + +def test_toolset_namespace_in_content_blocks() -> None: + block = { + "type": "tool_use", + "id": "call_1", + "name": "click", + "input": {}, + "toolset_name": "computer", + } + metadata = {"model_provider": "anthropic"} + message = AIMessage([block], response_metadata=metadata) + content_block = message.content_blocks[0] + assert content_block["type"] == "tool_call" + assert content_block["extras"]["toolset_name"] == "computer" + chunk = AIMessageChunk( + content=[{**block, "index": 0}], + tool_call_chunks=[ + { + "type": "tool_call_chunk", + "id": "call_1", + "name": "click", + "args": "{}", + "index": 0, + } + ], + response_metadata=metadata, + ) + chunk_block = chunk.content_blocks[0] + assert chunk_block["type"] == "tool_call_chunk" + assert chunk_block["extras"]["toolset_name"] == "computer" diff --git a/libs/core/tests/unit_tests/messages/test_ai.py b/libs/core/tests/unit_tests/messages/test_ai.py index e7d983d6ed..1b8da559fd 100644 --- a/libs/core/tests/unit_tests/messages/test_ai.py +++ b/libs/core/tests/unit_tests/messages/test_ai.py @@ -423,6 +423,18 @@ def test_content_blocks() -> None: {"type": "server_tool_call", "name": "foo", "index": 0, "args": {"a": 1}} ] + # Server tool calls with no input stream no args + empty_args_chunk = AIMessageChunk( + content=[ + {"type": "server_tool_call_chunk", "index": 0, "name": "foo", "args": ""} + ] + ) + AIMessageChunk( + content=[], chunk_position="last", response_metadata={"output_version": "v1"} + ) + assert empty_args_chunk.content == [ + {"type": "server_tool_call", "name": "foo", "index": 0, "args": {}} + ] + # Test non-standard + non-standard chunk_1 = AIMessageChunk( content=[ diff --git a/libs/partners/anthropic/README.md b/libs/partners/anthropic/README.md index f8dbfc20b0..18a56ee5e5 100644 --- a/libs/partners/anthropic/README.md +++ b/libs/partners/anthropic/README.md @@ -31,6 +31,27 @@ As an open-source project in a rapidly developing field, we are extremely open t For detailed information on how to contribute, see the [Contributing Guide](https://docs.langchain.com/oss/python/contributing/overview). +## Migrating to Claude Sonnet 5.5 + +```python +from langchain_anthropic import ChatAnthropic + +model = ChatAnthropic( + model="claude-sonnet-5-5", + max_tokens=16000, + output_config={"effort": "medium"}, +) +``` + +- Use `with_structured_output(schema, method="json_schema")` for native structured output. Sonnet 5.5 rejects forced tool choice (`"any"` or a tool name). Function-calling structured output does not force a call and raises a parsing error if the model answers without one. +- Thinking is adaptive by default. For no up-front thinking, use `thinking={"type": "between_tools"}` at `high` effort or below, with no additional thinking fields. `disabled` and budgeted `enabled` thinking are unsupported. +- Omit sampling settings; non-default `temperature`, `top_p`, and `top_k` are rejected. Budget output tokens for both thinking and text. +- Preserve signed thinking blocks, including empty ones, and keep history append-only. Use mid-conversation system messages to change instructions or tools rather than editing earlier turns. +- Progress updates can arrive as thinking blocks. Use adaptive thinking with `display="summarized"` or `display="updates"` to display them; the latter's beta header is added automatically. +- Computer use on the direct Claude API requires `computer_toolset_20260801`. Preserve the returned tool-use content: its `toolset_name` is retained on replay and copied to matching tool results. Remove the old fine-grained streaming beta when using toolsets. + +See the [migration guide](https://platform.claude.com/docs/en/models/sonnet-5-5/migration-guide) for platform-specific restrictions, advisor pairings, and refusal/fallback behavior. + ## Resources - [LangChain Academy](https://academy.langchain.com/) — comprehensive, free courses on LangChain libraries and products, made by the LangChain team diff --git a/libs/partners/anthropic/langchain_anthropic/_compat.py b/libs/partners/anthropic/langchain_anthropic/_compat.py index dc85be7bbe..8d76e379e6 100644 --- a/libs/partners/anthropic/langchain_anthropic/_compat.py +++ b/libs/partners/anthropic/langchain_anthropic/_compat.py @@ -129,8 +129,9 @@ def _convert_from_v1_to_anthropic( "input": block.get("args", {}), "id": block.get("id", ""), } - if "caller" in block.get("extras", {}): - tool_use_block["caller"] = block["extras"]["caller"] + for key in ("caller", "toolset_name"): + if key in block.get("extras", {}): + tool_use_block[key] = block["extras"][key] new_content.append(tool_use_block) elif block["type"] == "tool_call_chunk": @@ -147,6 +148,11 @@ def _convert_from_v1_to_anthropic( "name": block.get("name", ""), "input": input_, "id": block.get("id", ""), + **{ + key: block["extras"][key] + for key in ("caller", "toolset_name") + if key in block.get("extras", {}) + }, } ) diff --git a/libs/partners/anthropic/langchain_anthropic/chat_models.py b/libs/partners/anthropic/langchain_anthropic/chat_models.py index d15acdedef..01d8369bc0 100644 --- a/libs/partners/anthropic/langchain_anthropic/chat_models.py +++ b/libs/partners/anthropic/langchain_anthropic/chat_models.py @@ -720,6 +720,7 @@ def _format_messages( """Format messages for Anthropic's API.""" system: str | list[dict] | None = None formatted_messages: list[dict] = [] + toolsets: dict[str, str] = {} merged_messages = _merge_messages(messages) last_non_system_index = max( (i for i, m in enumerate(merged_messages) if m.type != "system"), @@ -795,11 +796,13 @@ def _format_messages( for tc in message.tool_calls if tc["id"] == block["id"] ] - content.extend( - _lc_tool_calls_to_anthropic_tool_use_blocks( - overlapping, - ), + tool_blocks = _lc_tool_calls_to_anthropic_tool_use_blocks( + overlapping, ) + if toolset_name := block.get("toolset_name"): + for tool_block in tool_blocks: + tool_block["toolset_name"] = toolset_name + content.extend(tool_blocks) else: if tool_input := block.get("input"): args = tool_input @@ -818,6 +821,8 @@ def _format_messages( ) if caller := block.get("caller"): tool_use_block["caller"] = caller + if toolset_name := block.get("toolset_name"): + tool_use_block["toolset_name"] = toolset_name content.append(tool_use_block) elif block["type"] in ("server_tool_use", "mcp_tool_use"): formatted_block = { @@ -944,6 +949,8 @@ def _format_messages( }, ), ) + elif block["type"] == "advisor_tool_result": + content.append({k: v for k, v in block.items() if k != "index"}) else: content.append(block) else: @@ -1036,6 +1043,16 @@ def _format_messages( system = _format_system_content(pending.content, model=model) _warn_system_message_hoisted(model) pending_system = [] + if isinstance(content, list): + for block in content: + if not isinstance(block, dict): + continue + if block.get("type") == "tool_use" and block.get("toolset_name"): + toolsets[block["id"]] = block["toolset_name"] + elif block.get("type") == "tool_result" and ( + toolset_name := toolsets.get(block.get("tool_use_id", "")) + ): + block.setdefault("toolset_name", toolset_name) formatted_messages.append({"role": role, "content": content}) formatted_messages.extend( @@ -1136,13 +1153,16 @@ def _supports_mid_conversation_system_messages(model: object) -> bool: "claude-mythos-5", "claude-opus-4-8", "claude-opus-5", + "claude-sonnet-5-5", ) ) def _supports_forced_tool_choice(model: str) -> bool: """Return whether the model accepts `tool_choice` types `any` and `tool`.""" - return not model.startswith(("claude-fable-5-1", "claude-opus-5-5")) + return not model.startswith( + ("claude-fable-5-1", "claude-opus-5-5", "claude-sonnet-5-5") + ) def _is_direct_anthropic_llm_type(llm_type: object) -> bool: @@ -1793,7 +1813,8 @@ class ChatAnthropic(BaseChatModel): output_config["effort"] = effort is_fable_model = self.model.startswith("claude-fable-5") - if is_fable_model: + is_sonnet_55 = self.model.startswith("claude-sonnet-5-5") + if is_fable_model or is_sonnet_55: top_k = request_config.get("top_k", self.top_k) top_p = request_config.get("top_p", self.top_p) temperature = request_config.get("temperature", self.temperature) @@ -1819,7 +1840,7 @@ class ChatAnthropic(BaseChatModel): raise ValueError(msg) if ( - (self.model.startswith("claude-opus-5") or is_fable_model) + (self.model.startswith("claude-opus-5") or is_fable_model or is_sonnet_55) and isinstance(thinking, Mapping) and thinking.get("type") == "enabled" ): @@ -2331,6 +2352,12 @@ class ChatAnthropic(BaseChatModel): warnings.warn("Received unexpected tool content block.", stacklevel=2) content_block = event.content_block.model_dump() + if event.content_block.type == "advisor_tool_result": + content_block = { + key: content_block[key] + for key in ("type", "tool_use_id", "content", "cache_control") + if key in content_block + } if "caller" in content_block and content_block["caller"] is None: content_block.pop("caller") content_block["index"] = event.index @@ -2464,6 +2491,8 @@ class ChatAnthropic(BaseChatModel): "stop_reason": event.delta.stop_reason, "stop_sequence": event.delta.stop_sequence, } + if stop_details := event.delta.model_dump().get("stop_details"): + response_metadata["stop_details"] = stop_details if context_management := getattr(event, "context_management", None): response_metadata["context_management"] = ( context_management.model_dump() @@ -2882,8 +2911,8 @@ class ChatAnthropic(BaseChatModel): - `'function_calling'` (default): Use forced tool calling to get structured output. When `thinking` is enabled, or on models that don't support forced tool use (Claude Opus 5.5, Claude - Fable 5.1), the tool call isn't forced, and a missing tool - call raises `OutputParserException`. + Fable 5.1, Claude Sonnet 5.5), the tool call isn't forced, + and a missing tool call raises `OutputParserException`. - `'json_schema'`: Use Claude's dedicated [structured output](https://platform.claude.com/docs/en/build-with-claude/structured-outputs) feature. @@ -3238,6 +3267,7 @@ class _AnthropicToolUse(TypedDict): input: dict id: str caller: NotRequired[dict[str, Any]] + toolset_name: NotRequired[str] def _lc_tool_calls_to_anthropic_tool_use_blocks( diff --git a/libs/partners/anthropic/langchain_anthropic/data/_profiles.py b/libs/partners/anthropic/langchain_anthropic/data/_profiles.py index ed58a3d40b..3ce0c7ebd2 100644 --- a/libs/partners/anthropic/langchain_anthropic/data/_profiles.py +++ b/libs/partners/anthropic/langchain_anthropic/data/_profiles.py @@ -488,4 +488,39 @@ _PROFILES: dict[str, dict[str, Any]] = { ], "reasoning_effort_default": "high", }, + "claude-sonnet-5-5": { + "name": "Claude Sonnet 5.5", + "release_date": "2026-09-28", + "last_updated": "2026-09-28", + "open_weights": False, + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "text_inputs": True, + "image_inputs": True, + "audio_inputs": False, + "pdf_inputs": True, + "video_inputs": False, + "text_outputs": True, + "image_outputs": False, + "audio_outputs": False, + "video_outputs": False, + "reasoning_output": True, + "tool_calling": True, + "structured_output": True, + "attachment": True, + "temperature": False, + "image_url_inputs": True, + "pdf_tool_message": True, + "image_tool_message": True, + "tool_call_streaming": True, + "tool_choice": False, + "reasoning_effort_levels": [ + "low", + "medium", + "high", + "xhigh", + "max", + ], + "reasoning_effort_default": "high", + }, } diff --git a/libs/partners/anthropic/langchain_anthropic/data/profile_augmentations.toml b/libs/partners/anthropic/langchain_anthropic/data/profile_augmentations.toml index 4c2a77a2ba..ba4367f1e2 100644 --- a/libs/partners/anthropic/langchain_anthropic/data/profile_augmentations.toml +++ b/libs/partners/anthropic/langchain_anthropic/data/profile_augmentations.toml @@ -53,6 +53,12 @@ structured_output = true reasoning_effort_levels = ["low", "medium", "high", "xhigh", "max"] reasoning_effort_default = "medium" +[overrides."claude-sonnet-5-5"] +structured_output = true +tool_choice = false +reasoning_effort_levels = ["low", "medium", "high", "xhigh", "max"] +reasoning_effort_default = "high" + [overrides."claude-sonnet-5"] structured_output = true reasoning_effort_levels = ["low", "medium", "high", "xhigh", "max"] diff --git a/libs/partners/anthropic/tests/unit_tests/test_sonnet55.py b/libs/partners/anthropic/tests/unit_tests/test_sonnet55.py new file mode 100644 index 0000000000..9ad8a745ad --- /dev/null +++ b/libs/partners/anthropic/tests/unit_tests/test_sonnet55.py @@ -0,0 +1,400 @@ +from typing import Any, cast +from unittest.mock import MagicMock, patch + +import pytest +from anthropic._models import construct_type +from anthropic.types import ( + RawContentBlockStartEvent, + RawMessageDeltaEvent, + ThinkingBlock, +) +from anthropic.types.beta import BetaRawMessageStreamEvent +from langchain_core.exceptions import OutputParserException +from langchain_core.messages import ( + AIMessage, + AIMessageChunk, + HumanMessage, + SystemMessage, + ToolMessage, +) +from langchain_core.runnables import RunnableBinding, RunnableSequence + +from langchain_anthropic import ChatAnthropic +from langchain_anthropic.chat_models import _format_messages + +MODEL = "claude-sonnet-5-5" +TOOL = {"name": "answer", "input_schema": {"type": "object", "properties": {}}} + + +def model(**kwargs: Any) -> ChatAnthropic: + return ChatAnthropic(model=MODEL, api_key="test", **kwargs) + + +@pytest.mark.parametrize( + ("choice", "expected"), + [ + ("any", {"type": "any"}), + ("answer", {"type": "tool", "name": "answer"}), + ({"type": "any"}, {"type": "any"}), + ({"type": "tool", "name": "answer"}, {"type": "tool", "name": "answer"}), + ], +) +def test_forced_tool_choice_left_to_api(choice: Any, expected: dict[str, str]) -> None: + llm = model() + assert ( + cast("RunnableBinding", llm.bind_tools([TOOL], tool_choice=choice)).kwargs[ + "tool_choice" + ] + == expected + ) + assert ( + llm._get_request_payload("hello", tool_choice=expected)["tool_choice"] + == expected + ) + + +def test_tool_choice_and_structured_output() -> None: + llm = model() + assert cast("RunnableBinding", llm.bind_tools([TOOL], tool_choice="auto")).kwargs[ + "tool_choice" + ] == {"type": "auto"} + with pytest.warns(UserWarning, match="json_schema"): + structured = llm.with_structured_output(TOOL) + assert ( + "tool_choice" + not in cast( + "RunnableBinding", cast("RunnableSequence", structured).first + ).kwargs + ) + native = llm.with_structured_output( + {"title": "Answer", "type": "object", "properties": {}}, method="json_schema" + ) + assert ( + cast("RunnableBinding", cast("RunnableSequence", native).first).kwargs[ + "output_config" + ]["format"]["type"] + == "json_schema" + ) + older = ChatAnthropic(model="claude-sonnet-5", api_key="test") + assert cast("RunnableBinding", older.bind_tools([TOOL], tool_choice="any")).kwargs[ + "tool_choice" + ] == {"type": "any"} + + +@pytest.mark.parametrize( + "kwargs", + [ + {"thinking": {"type": "disabled"}}, + {"thinking": {"type": "enabled", "budget_tokens": 1024}}, + {"temperature": 0}, + {"top_p": 0.5}, + {"top_k": 10}, + ], +) +def test_invalid_configuration(kwargs: dict[str, Any]) -> None: + with pytest.raises(ValueError): + model(**kwargs)._get_request_payload("hello") + with pytest.raises(ValueError): + model()._get_request_payload("hello", **kwargs) + + +@pytest.mark.parametrize("effort", ["low", "medium", "high", "xhigh", "max"]) +def test_between_tools(effort: str) -> None: + payload = model(thinking={"type": "between_tools"})._get_request_payload( + "hello", effort=effort + ) + assert payload["thinking"] == {"type": "between_tools"} + assert payload["output_config"] == {"effort": effort} + + +@pytest.mark.parametrize("extra", [{"display": "summarized"}, {"budget_tokens": 1024}]) +def test_between_tools_extra_fields_left_to_api(extra: dict[str, Any]) -> None: + thinking = {"type": "between_tools", **extra} + assert ( + model(thinking=thinking)._get_request_payload("hello")["thinking"] == thinking + ) + + +def test_profile_and_defaults() -> None: + llm = model() + assert llm.max_tokens == 128000 + assert llm.profile is not None + assert llm.profile["max_input_tokens"] == 1000000 + assert llm.profile["structured_output"] is True + assert llm.profile["tool_choice"] is False + assert llm.profile["reasoning_effort_levels"] == [ + "low", + "medium", + "high", + "xhigh", + "max", + ] + assert llm.profile["reasoning_effort_default"] == "high" + payload = llm._get_request_payload("hello") + assert not {"temperature", "top_p", "top_k", "thinking"} & payload.keys() + assert llm._get_request_payload("hello", effort="medium")["thinking"] == { + "type": "adaptive", + "display": "summarized", + } + + +def test_mid_conversation_system() -> None: + system, messages = _format_messages( + [ + SystemMessage("initial"), + HumanMessage("hello"), + SystemMessage("new instructions"), + AIMessage("answer"), + HumanMessage("next"), + ], + model=MODEL, + ) + assert system == "initial" + assert [m["role"] for m in messages] == ["user", "system", "assistant", "user"] + + +@pytest.mark.parametrize("standard", [False, True]) +def test_toolset_round_trip(*, standard: bool) -> None: + content: list[str | dict[str, Any]] = [ + {"type": "thinking", "thinking": "", "signature": "opaque-signature"}, + { + "type": "tool_use", + "id": "call_1", + "name": "click", + "toolset_name": "computer", + "input": {"x": 1}, + }, + ] + ai = AIMessage( + content=content, + tool_calls=[ + {"name": "click", "id": "call_1", "args": {"x": 2}, "type": "tool_call"} + ], + response_metadata={"model_provider": "anthropic"}, + ) + if standard: + ai = ai.model_copy( + update={ + "content": ai.content_blocks, + "response_metadata": { + "model_provider": "anthropic", + "output_version": "v1", + }, + } + ) + payload = model()._get_request_payload( + [HumanMessage("click"), ai, ToolMessage("done", tool_call_id="call_1")] + ) + assert payload["messages"][1]["content"][0] == content[0] + tool = payload["messages"][1]["content"][1] + assert tool["toolset_name"] == "computer" + assert tool["input"] == {"x": 2} + assert payload["messages"][2]["content"][0]["toolset_name"] == "computer" + + +def test_encrypted_advisor_streaming() -> None: + advisor_result = { + "type": "advisor_tool_result", + "tool_use_id": "srvtoolu_abc123", + "content": { + "type": "advisor_redacted_result", + "encrypted_content": "opaque-ciphertext", + }, + } + event = cast( + RawContentBlockStartEvent, + construct_type( + type_=RawContentBlockStartEvent, + value={ + "type": "content_block_start", + "index": 0, + "content_block": advisor_result, + }, + ), + ) + llm = model() + chunk, _ = llm._make_message_chunk_from_anthropic_event( + event, stream_usage=True, coerce_content_to_string=False, block_start_event=None + ) + assert chunk is not None + assert chunk.content == [{**advisor_result, "index": 0}] + payload = llm._get_request_payload( + [HumanMessage("help"), chunk, HumanMessage("continue")] + ) + assert payload["messages"][1]["content"] == [advisor_result] + + +@pytest.mark.parametrize("output_version", ["v0", "v1"]) +def test_encrypted_advisor_stream_aggregate_replay(output_version: str) -> None: + server_tool_use = { + "type": "server_tool_use", + "id": "srvtoolu_abc123", + "name": "advisor", + "input": {}, + } + advisor_result = { + "type": "advisor_tool_result", + "tool_use_id": "srvtoolu_abc123", + "content": { + "type": "advisor_redacted_result", + "encrypted_content": "opaque-ciphertext", + "stop_reason": None, + }, + } + raw_events = [ + { + "type": "message_start", + "message": { + "id": "msg_1", + "type": "message", + "role": "assistant", + "model": MODEL, + "content": [], + "stop_reason": None, + "stop_sequence": None, + "usage": {"input_tokens": 10, "output_tokens": 1}, + }, + }, + {"type": "content_block_start", "index": 0, "content_block": server_tool_use}, + {"type": "content_block_stop", "index": 0}, + {"type": "content_block_start", "index": 1, "content_block": advisor_result}, + {"type": "content_block_stop", "index": 1}, + { + "type": "content_block_start", + "index": 2, + "content_block": {"type": "text", "text": ""}, + }, + { + "type": "content_block_delta", + "index": 2, + "delta": {"type": "text_delta", "text": "Use a token bucket."}, + }, + {"type": "content_block_stop", "index": 2}, + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"output_tokens": 20}, + }, + {"type": "message_stop"}, + ] + events = [ + construct_type(type_=BetaRawMessageStreamEvent, value=event) + for event in raw_events + ] + llm = model(output_version=output_version).bind_tools( + [{"type": "advisor_20260301", "name": "advisor", "model": "claude-opus-5"}] + ) + with patch.object( + ChatAnthropic, "_create", return_value=MagicMock(parse=lambda: iter(events)) + ): + chunks = [cast("AIMessageChunk", chunk) for chunk in llm.stream("help")] + full = chunks[0] + for chunk in chunks[1:]: + full += chunk + + payload = model()._get_request_payload( + [HumanMessage("help"), full, HumanMessage("continue")] + ) + assert payload["messages"][1]["content"] == [ + server_tool_use, + advisor_result, + {"type": "text", "text": "Use a token bucket."}, + ] + + +def test_refusal_details_streaming() -> None: + event = RawMessageDeltaEvent.model_validate( + { + "type": "message_delta", + "delta": { + "stop_reason": "refusal", + "stop_sequence": None, + "stop_details": {"type": "refusal", "category": "cyber"}, + }, + "usage": {"output_tokens": 3}, + } + ) + chunk, _ = model()._make_message_chunk_from_anthropic_event( + event, stream_usage=True, coerce_content_to_string=False, block_start_event=None + ) + assert chunk is not None + assert chunk.response_metadata["stop_details"]["category"] == "cyber" + + +def test_unforced_structured_output_requires_tool_call() -> None: + with pytest.warns(UserWarning, match="json_schema"): + structured = model().with_structured_output(TOOL) + check = cast("RunnableSequence", structured).steps[1] + with pytest.raises(OutputParserException): + check.invoke(AIMessage("No tool call")) + + +def test_mid_conversation_tool_change() -> None: + block = { + "type": "tool_addition", + "tool": {"type": "tool_reference", "name": "answer"}, + } + _, messages = _format_messages( + [HumanMessage("hello"), SystemMessage([block]), AIMessage("answer")], + model=MODEL, + ) + assert messages[1] == {"role": "system", "content": [block]} + + +def test_default_thinking_stream_preserves_signature() -> None: + event = RawContentBlockStartEvent( + type="content_block_start", + index=0, + content_block=ThinkingBlock( + type="thinking", thinking="", signature="opaque-signature" + ), + ) + chunk, _ = model()._make_message_chunk_from_anthropic_event( + event, stream_usage=True, coerce_content_to_string=True, block_start_event=None + ) + assert chunk is not None + payload = model()._get_request_payload( + [HumanMessage("hello"), chunk, HumanMessage("next")] + ) + assert payload["messages"][1]["content"] == [ + {"type": "thinking", "thinking": "", "signature": "opaque-signature"} + ] + + +def test_standard_tool_chunk_namespace_replay() -> None: + chunk = AIMessageChunk( + content=[ + { + "type": "tool_use", + "name": "click", + "id": "call_1", + "input": {}, + "toolset_name": "computer", + "index": 0, + } + ], + tool_call_chunks=[ + { + "type": "tool_call_chunk", + "name": "click", + "id": "call_1", + "args": "{}", + "index": 0, + } + ], + response_metadata={"model_provider": "anthropic"}, + ) + chunk = chunk.model_copy( + update={ + "content": chunk.content_blocks, + "response_metadata": { + "model_provider": "anthropic", + "output_version": "v1", + }, + } + ) + payload = model()._get_request_payload( + [HumanMessage("click"), chunk, ToolMessage("done", tool_call_id="call_1")] + ) + assert payload["messages"][1]["content"][0]["toolset_name"] == "computer" + assert payload["messages"][2]["content"][0]["toolset_name"] == "computer"