diff --git a/libs/partners/anthropic/langchain_anthropic/_compat.py b/libs/partners/anthropic/langchain_anthropic/_compat.py index 44a2070487..dc85be7bbe 100644 --- a/libs/partners/anthropic/langchain_anthropic/_compat.py +++ b/libs/partners/anthropic/langchain_anthropic/_compat.py @@ -6,6 +6,16 @@ from typing import Any, cast from langchain_core.messages import content as types +def _unwrap_non_standard(block: dict) -> dict: + """Unwrap a provider-native dictionary from a standard content block.""" + if block.get("type") == "non_standard" and isinstance( + value := block.get("value"), + dict, + ): + return value + return block + + def _convert_annotation_from_v1(annotation: types.Annotation) -> dict[str, Any]: """Convert LangChain annotation format to Anthropic's native citation format.""" if annotation["type"] == "non_standard_annotation": diff --git a/libs/partners/anthropic/langchain_anthropic/chat_models.py b/libs/partners/anthropic/langchain_anthropic/chat_models.py index f9541d7052..a13a6ff1d0 100644 --- a/libs/partners/anthropic/langchain_anthropic/chat_models.py +++ b/libs/partners/anthropic/langchain_anthropic/chat_models.py @@ -83,7 +83,10 @@ from langchain_anthropic._client_utils import ( _get_default_async_httpx_client, _get_default_httpx_client, ) -from langchain_anthropic._compat import _convert_from_v1_to_anthropic +from langchain_anthropic._compat import ( + _convert_from_v1_to_anthropic, + _unwrap_non_standard, +) from langchain_anthropic._sdk_compat import ( _aparse, _route_unsupported_sampling_params, @@ -536,22 +539,108 @@ def _format_text_block(block: dict) -> dict: return formatted_block -def _format_system_content(content: str | list[Any]) -> str | list[dict]: +_TOOL_CHANGE_BLOCK_TYPES = ("tool_addition", "tool_removal") +"""Anthropic-native system content blocks that change the tool set mid-conversation.""" + +_MID_CONVERSATION_TOOL_CHANGES_BETA = "mid-conversation-tool-changes-2026-07-01" +"""Beta header required to send `tool_addition` / `tool_removal` blocks.""" + +_INLINE_TOOLS_BETA = "inline-tools-2026-09-15" +"""Beta header required to define a tool in a `tool_addition` block.""" + + +def _is_tool_change_block(block: object) -> bool: + """Return whether a content block changes the tool set, in either spelling.""" + return ( + isinstance(block, dict) + and _unwrap_non_standard(block).get("type") in _TOOL_CHANGE_BLOCK_TYPES + ) + + +def _has_tool_change_block(content: object) -> bool: + """Return whether any block in `content` changes the tool set.""" + return isinstance(content, list) and any(_is_tool_change_block(b) for b in content) + + +def _has_inline_tool_definition(content: object) -> bool: + """Return whether `content` defines a tool in a `tool_addition` block.""" + if not isinstance(content, list): + return False + return any( + isinstance(block, dict) + and block.get("type") == "tool_addition" + and isinstance(tool := block.get("tool"), dict) + and tool.get("type") == "tool_definition" + for block in content + ) + + +def _format_system_content( + content: str | list[Any], + *, + model: str | None = None, + preserve_tool_changes: bool = False, + stacklevel: int = 3, +) -> str | list[dict]: """Narrow system message content to what Anthropic accepts. String content is passed through unchanged; promoting it to a single-element block array would invalidate existing callers' prompt caches. + + Anthropic documents a closed set of system content blocks: `text` anywhere, plus + `tool_addition` / `tool_removal` on a mid-conversation `system` turn. Anything + else is rejected by the API, so it is dropped with a warning rather than + forwarded. Blocks wrapped in core's `non_standard` escape hatch are unwrapped + first, so both spellings behave identically. + + Args: + content: The system message's content. + model: The model the request targets, used only in warning text. + preserve_tool_changes: Whether tool-change blocks should be forwarded instead + of dropped. + stacklevel: Frames to skip when attributing a warning, so it points at the + caller of `_format_messages` rather than at this module. The default + suits a direct call; a caller reached through a helper adds a frame. + + Returns: + Content narrowed to the blocks Anthropic accepts in this position. """ - if isinstance(content, list): - return [ - ( - (_format_text_block(block) if block.get("type") == "text" else block) - if isinstance(block, dict) - else {"type": "text", "text": block} + if not isinstance(content, list): + return content + + formatted: list[dict] = [] + for raw_block in content: + if not isinstance(raw_block, dict): + formatted.append({"type": "text", "text": raw_block}) + continue + + block = _unwrap_non_standard(raw_block) + block_type = block.get("type") + if block_type == "text": + formatted.append(_format_text_block(block)) + elif block_type in _TOOL_CHANGE_BLOCK_TYPES: + if preserve_tool_changes: + formatted.append(block) + else: + warnings.warn( + f"Tool-change block {block_type!r} was dropped: it is only " + "valid on a `SystemMessage` sent in place, and this one was " + "hoisted into the top-level `system` field. Use a model that " + "supports mid-conversation system messages and place the " + "message after a human or tool message, either last or before " + f"an AI message (model: {model!r}).", + UserWarning, + stacklevel=stacklevel, + ) + else: + warnings.warn( + f"Unrecognized system content block {block_type!r} was dropped. " + "Anthropic accepts `text` in any system message, plus " + "`tool_addition` and `tool_removal` on a mid-conversation one.", + UserWarning, + stacklevel=stacklevel, ) - for block in content - ] - return content + return formatted def _warn_system_message_hoisted(model: str | None) -> None: @@ -590,6 +679,39 @@ def _previous_turn_allows_system(previous_turn: dict | None) -> bool: ) +def _format_in_place_system_messages( + pending_system: Sequence[BaseMessage], + *, + model: str | None, +) -> list[dict]: + """Format system messages that keep their position in the message array. + + A message whose content is narrowed away entirely is omitted: Anthropic + rejects a `system` turn with empty content, and the dropped blocks have + already been warned about. + + Args: + pending_system: System messages awaiting emission, in order. + model: The model the request targets, used only in warning text. + + Returns: + Formatted `system`-role turns. + """ + turns: list[dict] = [] + for pending in pending_system: + content = _format_system_content( + pending.content, + model=model, + preserve_tool_changes=True, + # This helper sits between `_format_messages` and the warning site. + stacklevel=4, + ) + if content == []: + continue + turns.append({"role": "system", "content": content}) + return turns + + def _format_messages( messages: Sequence[BaseMessage], *, @@ -607,7 +729,11 @@ def _format_messages( for _i, message in enumerate(merged_messages): if message.type == "system": if _i == 0: - system = _format_system_content(message.content) + system = _format_system_content( + message.content, + model=model, + preserve_tool_changes=True, + ) continue if _supports_mid_conversation_system_messages(model) and ( pending_system @@ -620,7 +746,7 @@ def _format_messages( if system is not None: msg = "Received multiple non-consecutive system messages." raise ValueError(msg) - system = _format_system_content(message.content) + system = _format_system_content(message.content, model=model) _warn_system_message_hoisted(model) continue @@ -874,25 +1000,20 @@ def _format_messages( if pending_system: if role == "assistant": formatted_messages.extend( - { - "role": "system", - "content": _format_system_content(pending.content), - } - for pending in pending_system + _format_in_place_system_messages(pending_system, model=model) ) else: for pending in pending_system: if system is not None: msg = "Received multiple non-consecutive system messages." raise ValueError(msg) - system = _format_system_content(pending.content) + system = _format_system_content(pending.content, model=model) _warn_system_message_hoisted(model) pending_system = [] formatted_messages.append({"role": role, "content": content}) formatted_messages.extend( - {"role": "system", "content": _format_system_content(pending.content)} - for pending in pending_system + _format_in_place_system_messages(pending_system, model=model) ) return system, formatted_messages @@ -1183,6 +1304,37 @@ class ChatAnthropic(BaseChatModel): ) ``` + Example: Add a tool mid-conversation + ```python + from langchain_core.messages import HumanMessage, SystemMessage + from langchain_anthropic import ChatAnthropic + + model = ChatAnthropic(model="claude-opus-5-5") + model.invoke( + [ + HumanMessage("What time is it?"), + SystemMessage( + [ + { + "type": "tool_addition", + "tool": { + "type": "tool_definition", + "definition": { + "name": "get_time", + "description": "Get the current time.", + "input_schema": { + "type": "object", + "properties": {}, + }, + }, + }, + } + ] + ), + ] + ) + ``` + Note: Any param which is not explicitly supported will be passed directly to [`Anthropic.messages.create(...)`](https://platform.claude.com/docs/en/api/python/messages/create) @@ -1703,6 +1855,12 @@ class ChatAnthropic(BaseChatModel): ) system, formatted_messages = _format_messages(messages, model=self.model) + if isinstance(system, list) and not system: + # Every block was narrowed away (or the message was empty to begin + # with). An empty block array carries no instructions, so drop the + # field rather than sending it. `_format_messages` has already + # claimed the slot, so a second hoisted system message still errors. + system = None # Only the direct Anthropic API accepts top-level `cache_control`. # Subclasses that route through other transports (e.g. Bedrock) expand @@ -1929,6 +2087,28 @@ class ChatAnthropic(BaseChatModel): else: payload["betas"] = [required_beta] + system_contents = [ + message.get("content") + for message in (payload.get("messages") or []) + if message.get("role") == "system" + ] + has_tool_change = any(_has_tool_change_block(c) for c in system_contents) + has_inline_definition = any( + _has_inline_tool_definition(c) for c in system_contents + ) + explicit_betas = payload.get("betas") or [] + tool_change_beta = ( + _INLINE_TOOLS_BETA + if has_inline_definition + else ( + _MID_CONVERSATION_TOOL_CHANGES_BETA + if has_tool_change and _INLINE_TOOLS_BETA not in explicit_betas + else None + ) + ) + if tool_change_beta and tool_change_beta not in explicit_betas: + payload["betas"] = [*explicit_betas, tool_change_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_system_tool_addition.yaml.gz b/libs/partners/anthropic/tests/cassettes/test_system_tool_addition.yaml.gz new file mode 100644 index 0000000000..7598d5ab73 Binary files /dev/null and b/libs/partners/anthropic/tests/cassettes/test_system_tool_addition.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 7c487c45bf..70900e95d4 100644 --- a/libs/partners/anthropic/tests/integration_tests/test_chat_models.py +++ b/libs/partners/anthropic/tests/integration_tests/test_chat_models.py @@ -783,6 +783,36 @@ def test_strict_tool_use() -> None: assert response.tool_calls +@pytest.mark.vcr +def test_system_tool_addition() -> None: + model = ChatAnthropic(model="claude-opus-5-5") # type: ignore[call-arg] + response = model.invoke( + [ + HumanMessage("What time is it?"), + SystemMessage( + [ + { + "type": "tool_addition", + "tool": { + "type": "tool_definition", + "definition": { + "name": "get_time", + "description": "Get the current time.", + "input_schema": { + "type": "object", + "properties": {}, + }, + }, + }, + } + ] + ), + ] + ) + assert isinstance(response, AIMessage) + assert response.tool_calls[0]["name"] == "get_time" + + def test_get_num_tokens_from_messages() -> None: llm = ChatAnthropic(model=MODEL_NAME) # type: ignore[call-arg] 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 3a9d67905c..704409d71b 100644 --- a/libs/partners/anthropic/tests/unit_tests/test_chat_models.py +++ b/libs/partners/anthropic/tests/unit_tests/test_chat_models.py @@ -30,6 +30,7 @@ from langchain_core.exceptions import ( from langchain_core.messages import ( AIMessage, AIMessageChunk, + BaseMessage, HumanMessage, SystemMessage, ToolMessage, @@ -1896,15 +1897,12 @@ def test__format_messages_system_after_server_tool_result_sent_in_place( block_type: str, ) -> None: """An assistant turn ending in a server tool result is a legal predecessor.""" - ai = AIMessage( - [ - { - "type": block_type, - "tool_use_id": "srvtoolu_1", - "content": [{"type": "text", "text": "results"}], - }, - ], - ) + block = { + "type": block_type, + "tool_use_id": "srvtoolu_1", + "content": [{"type": "text", "text": "results"}], + } + ai = AIMessage([block], response_metadata={"model_provider": "anthropic"}) messages = [ HumanMessage("Review foo()"), ai, @@ -1919,6 +1917,9 @@ def test__format_messages_system_after_server_tool_result_sent_in_place( "assistant", "system", ] + # Forward compatibility: an unrecognized server tool result block reaches the + # wire untouched, which is what makes it a legal predecessor in the first place. + assert actual_messages[1]["content"] == [block] def test__format_messages_system_after_client_tool_result_hoisted() -> None: @@ -2284,6 +2285,234 @@ def test__format_messages_system_citations_preserved_in_place() -> None: } +_TOOL_REMOVAL_BLOCK = { + "type": "tool_removal", + "tool": {"type": "tool_reference", "name": "get_weather"}, +} +_TOOL_ADDITION_BLOCK = { + "type": "tool_addition", + "tool": {"type": "tool_reference", "name": "get_weather"}, +} +_TOOL_CHANGE_UNSUPPORTED_WARNING = "Tool-change block" +_UNRECOGNIZED_SYSTEM_BLOCK_WARNING = "Unrecognized system content block" + + +@pytest.mark.parametrize("block", [_TOOL_REMOVAL_BLOCK, _TOOL_ADDITION_BLOCK]) +def test__format_messages_system_tool_change_block_sent_in_place( + block: dict, +) -> None: + """Tool-change blocks reach the wire verbatim from an in-place system message.""" + messages = [ + HumanMessage("Review foo()"), + SystemMessage([block]), + AIMessage("Looks fine."), + ] + actual_system, actual_messages = _format_messages( + messages, model=MID_CONVERSATION_SYSTEM_MODEL + ) + assert actual_system is None + assert actual_messages[1] == {"role": "system", "content": [block]} + + +@pytest.mark.parametrize("block", [_TOOL_REMOVAL_BLOCK, _TOOL_ADDITION_BLOCK]) +def test__format_messages_system_tool_change_block_spellings_match( + block: dict, +) -> None: + """A bare dict and a `non_standard` wrapper produce identical wire output.""" + bare = [ + HumanMessage("Review foo()"), + SystemMessage([block]), + AIMessage("Looks fine."), + ] + wrapped = [ + HumanMessage("Review foo()"), + SystemMessage([{"type": "non_standard", "value": block}]), + AIMessage("Looks fine."), + ] + assert _format_messages(bare, model=MID_CONVERSATION_SYSTEM_MODEL) == ( + _format_messages(wrapped, model=MID_CONVERSATION_SYSTEM_MODEL) + ) + + +@pytest.mark.parametrize("block", [_TOOL_REMOVAL_BLOCK, _TOOL_ADDITION_BLOCK]) +def test__format_messages_tool_change_block_survives_content_blocks( + block: dict, +) -> None: + """The payload reaches the converter untranslated via either accessor. + + A bare provider-native dict wraps into `non_standard` when read through + `content_blocks`, and an already-wrapped one stays put; neither is rewritten. + The converter unwraps both, so the accessor a caller uses cannot change the + wire output. + """ + bare = SystemMessage([block]) + wrapped = SystemMessage(content_blocks=[{"type": "non_standard", "value": block}]) + expected = [{"type": "non_standard", "value": block}] + assert bare.content_blocks == expected + assert wrapped.content_blocks == expected + + conversation = [HumanMessage("Review foo()"), AIMessage("Looks fine.")] + assert _format_messages( + [conversation[0], bare, conversation[1]], + model=MID_CONVERSATION_SYSTEM_MODEL, + ) == _format_messages( + [conversation[0], wrapped, conversation[1]], + model=MID_CONVERSATION_SYSTEM_MODEL, + ) + + +def test__format_messages_system_tool_change_block_beside_text() -> None: + """Siblings of a tool-change block survive alongside it.""" + messages = [ + HumanMessage("Review foo()"), + SystemMessage([{"type": "text", "text": "Be concise."}, _TOOL_REMOVAL_BLOCK]), + AIMessage("Looks fine."), + ] + _, actual_messages = _format_messages(messages, model=MID_CONVERSATION_SYSTEM_MODEL) + assert actual_messages[1] == { + "role": "system", + "content": [{"type": "text", "text": "Be concise."}, _TOOL_REMOVAL_BLOCK], + } + + +def test__format_messages_system_unrecognized_block_dropped_with_warning() -> None: + """An unrecognized system content block is dropped with a warning.""" + messages = [ + HumanMessage("Review foo()"), + SystemMessage( + [ + {"type": "text", "text": "Be concise."}, + {"type": "made_up_block", "foo": "bar"}, + ], + ), + AIMessage("Looks fine."), + ] + with pytest.warns(UserWarning, match=_UNRECOGNIZED_SYSTEM_BLOCK_WARNING): + _, actual_messages = _format_messages( + messages, model=MID_CONVERSATION_SYSTEM_MODEL + ) + assert actual_messages[1] == { + "role": "system", + "content": [{"type": "text", "text": "Be concise."}], + } + + +def test__format_messages_leading_system_unrecognized_block_dropped() -> None: + """The hoisted leading system path drops unrecognized blocks too.""" + messages = [ + SystemMessage( + [ + {"type": "text", "text": "Be concise."}, + {"type": "made_up_block", "foo": "bar"}, + ], + ), + HumanMessage("Review foo()"), + ] + with pytest.warns(UserWarning, match=_UNRECOGNIZED_SYSTEM_BLOCK_WARNING): + actual_system, _ = _format_messages( + messages, model=MID_CONVERSATION_SYSTEM_MODEL + ) + assert actual_system == [{"type": "text", "text": "Be concise."}] + + +@pytest.mark.parametrize("block", [_TOOL_REMOVAL_BLOCK, _TOOL_ADDITION_BLOCK]) +@pytest.mark.parametrize("spelling", ["bare", "non_standard"]) +def test__format_messages_leading_system_tool_change_block_forwarded( + block: dict, + spelling: str, +) -> None: + """Anthropic validates tool-change blocks on the top-level system field.""" + content: list[str | dict] = ( + [block] if spelling == "bare" else [{"type": "non_standard", "value": block}] + ) + actual_system, actual_messages = _format_messages( + [SystemMessage(content), HumanMessage("Review foo()")], + model=MID_CONVERSATION_SYSTEM_MODEL, + ) + assert actual_system == [block] + assert actual_messages == [{"role": "user", "content": "Review foo()"}] + + +def test__format_messages_system_tool_change_block_stripped_on_unsupported_model() -> ( + None +): + """An unsupported model strips the tool-change block and hoists the text.""" + messages = [ + HumanMessage("Review foo()"), + SystemMessage([{"type": "text", "text": "Be concise."}, _TOOL_REMOVAL_BLOCK]), + AIMessage("Looks fine."), + ] + with pytest.warns(UserWarning, match=_TOOL_CHANGE_UNSUPPORTED_WARNING) as record: + actual_system, actual_messages = _format_messages(messages, model=MODEL_NAME) + messages_warned = [str(warning.message) for warning in record] + assert any(_TOOL_CHANGE_UNSUPPORTED_WARNING in m for m in messages_warned) + assert any(_HOIST_WARNING in m for m in messages_warned) + assert actual_system == [{"type": "text", "text": "Be concise."}] + assert [message["role"] for message in actual_messages] == ["user", "assistant"] + + +def test__format_messages_system_tool_change_block_stripped_on_illegal_position() -> ( + None +): + """A hoisted system message on a supported model also strips tool changes.""" + messages = [ + HumanMessage("Review foo()"), + AIMessage("Looks fine."), + SystemMessage([{"type": "text", "text": "Be concise."}, _TOOL_REMOVAL_BLOCK]), + ] + with pytest.warns(UserWarning, match=_TOOL_CHANGE_UNSUPPORTED_WARNING) as record: + actual_system, _ = _format_messages( + messages, model=MID_CONVERSATION_SYSTEM_MODEL + ) + messages_warned = [str(warning.message) for warning in record] + assert any(_TOOL_CHANGE_UNSUPPORTED_WARNING in m for m in messages_warned) + assert actual_system == [{"type": "text", "text": "Be concise."}] + + +def test__format_messages_system_stripped_to_empty_hoists_empty_list() -> None: + """Stripping every block leaves an empty system array, not `None`.""" + messages = [ + HumanMessage("Review foo()"), + SystemMessage([_TOOL_REMOVAL_BLOCK]), + AIMessage("Looks fine."), + ] + with pytest.warns(UserWarning, match=_TOOL_CHANGE_UNSUPPORTED_WARNING): + actual_system, _ = _format_messages(messages, model=MODEL_NAME) + assert actual_system == [] + + +def test__format_messages_does_not_mutate_input_content() -> None: + """Formatting must leave the caller's own message content untouched.""" + text_block = {"type": "text", "text": "Be concise.", "id": "lc_abc123"} + tool_change_block = { + "type": "tool_removal", + "tool": {"type": "tool_reference", "name": "get_weather"}, + } + content: list[str | dict] = [text_block, tool_change_block] + before = copy.deepcopy(content) + _format_messages( + [ + HumanMessage("Review foo()"), + SystemMessage(content), + AIMessage("Looks fine."), + ], + model=MID_CONVERSATION_SYSTEM_MODEL, + ) + assert content == before + + +def test__format_messages_human_native_image_block_preserved() -> None: + """Regression guard: native Anthropic image blocks still reach the wire.""" + block = { + "type": "image", + "source": {"type": "base64", "media_type": "image/png", "data": "aGk="}, + } + _, actual_messages = _format_messages( + [HumanMessage([block])], model=MID_CONVERSATION_SYSTEM_MODEL + ) + assert actual_messages == [{"role": "user", "content": [block]}] + + def test__format_messages_requires_model() -> None: """Test the model argument is required.""" with pytest.raises(TypeError): @@ -5412,6 +5641,134 @@ def test_no_task_budget_no_beta() -> None: assert "task-budgets-2026-03-13" not in betas +_MID_CONVERSATION_TOOL_CHANGES_BETA = "mid-conversation-tool-changes-2026-07-01" +_INLINE_TOOLS_BETA = "inline-tools-2026-09-15" +_INLINE_TOOL_ADDITION_BLOCK = { + "type": "tool_addition", + "tool": { + "type": "tool_definition", + "definition": { + "name": "db_query", + "description": "Run a read-only query.", + "input_schema": { + "type": "object", + "properties": {"sql": {"type": "string"}}, + "required": ["sql"], + }, + }, + }, +} + + +def _tool_change_conversation() -> list[BaseMessage]: + return [ + HumanMessage("Review foo()"), + SystemMessage([_TOOL_REMOVAL_BLOCK]), + AIMessage("Looks fine."), + ] + + +def test_tool_change_block_auto_appends_beta() -> None: + """A tool-change block that reaches the wire enables the beta.""" + model = ChatAnthropic(model=MID_CONVERSATION_SYSTEM_MODEL) + payload = model._get_request_payload(_tool_change_conversation()) + assert payload["betas"] == [_MID_CONVERSATION_TOOL_CHANGES_BETA] + + +def test_tool_change_block_beta_not_duplicated() -> None: + """The tool-change beta is appended at most once.""" + model = ChatAnthropic( + model=MID_CONVERSATION_SYSTEM_MODEL, + betas=[_MID_CONVERSATION_TOOL_CHANGES_BETA], + ) + payload = model._get_request_payload(_tool_change_conversation()) + assert payload["betas"].count(_MID_CONVERSATION_TOOL_CHANGES_BETA) == 1 + + +@pytest.mark.parametrize("spelling", ["bare", "non_standard"]) +def test_inline_tool_definition_auto_appends_beta(spelling: str) -> None: + """Inline definitions use their beta and preserve the native wire payload.""" + block = ( + _INLINE_TOOL_ADDITION_BLOCK + if spelling == "bare" + else {"type": "non_standard", "value": _INLINE_TOOL_ADDITION_BLOCK} + ) + model = ChatAnthropic(model="claude-opus-5-5") + payload = model._get_request_payload( + [HumanMessage("Review data"), SystemMessage([block])] + ) + assert payload["messages"][-1]["content"] == [_INLINE_TOOL_ADDITION_BLOCK] + assert payload["betas"] == [_INLINE_TOOLS_BETA] + + +def test_inline_tool_definition_supersedes_reference_beta() -> None: + """One inline beta covers mixed inline and reference-based changes.""" + model = ChatAnthropic(model="claude-opus-5-5") + payload = model._get_request_payload( + [ + HumanMessage("Review data"), + SystemMessage([_TOOL_REMOVAL_BLOCK, _INLINE_TOOL_ADDITION_BLOCK]), + ] + ) + assert payload["betas"] == [_INLINE_TOOLS_BETA] + + +def test_explicit_inline_beta_covers_reference_tool_change() -> None: + """An explicit inline beta prevents inference of the older reference beta.""" + model = ChatAnthropic( + model="claude-opus-5-5", + betas=[_INLINE_TOOLS_BETA], + ) + payload = model._get_request_payload(_tool_change_conversation()) + assert payload["betas"] == [_INLINE_TOOLS_BETA] + + +@pytest.mark.parametrize( + "betas", + [ + [_INLINE_TOOLS_BETA], + [_MID_CONVERSATION_TOOL_CHANGES_BETA, _INLINE_TOOLS_BETA], + ], +) +def test_inline_tool_beta_preserved_without_duplication(betas: list[str]) -> None: + """Explicit beta order is preserved and inline beta is not duplicated.""" + model = ChatAnthropic(model="claude-opus-5-5", betas=betas) + payload = model._get_request_payload( + [HumanMessage("Review data"), SystemMessage([_INLINE_TOOL_ADDITION_BLOCK])] + ) + assert payload["betas"] == betas + assert payload["betas"].count(_INLINE_TOOLS_BETA) == 1 + + +def test_system_stripped_to_empty_omits_system_field() -> None: + """An empty block array carries no instructions, so it is not sent.""" + model = ChatAnthropic(model=MODEL_NAME) + with pytest.warns(UserWarning, match="Tool-change block"): + payload = model._get_request_payload(_tool_change_conversation()) + assert "system" not in payload + + +def test_no_tool_change_block_no_beta() -> None: + """A conversation without tool-change blocks does not enable the beta.""" + model = ChatAnthropic(model=MID_CONVERSATION_SYSTEM_MODEL) + payload = model._get_request_payload( + [ + HumanMessage("Review foo()"), + SystemMessage("Be concise."), + AIMessage("Looks fine."), + ], + ) + assert _MID_CONVERSATION_TOOL_CHANGES_BETA not in (payload.get("betas") or []) + + +def test_stripped_tool_change_block_no_beta() -> None: + """A tool-change block narrowed away on an unsupported model enables nothing.""" + model = ChatAnthropic(model=MODEL_NAME) + with pytest.warns(UserWarning, match="Tool-change block"): + payload = model._get_request_payload(_tool_change_conversation()) + assert _MID_CONVERSATION_TOOL_CHANGES_BETA not in (payload.get("betas") or []) + + def test_anthropic_stream_events_v3_lifecycle() -> None: """Validate lifecycle events across a thinking + text + tool_use stream. diff --git a/libs/partners/openai/langchain_openai/chat_models/_compat.py b/libs/partners/openai/langchain_openai/chat_models/_compat.py index a64cfa6db8..7919d416da 100644 --- a/libs/partners/openai/langchain_openai/chat_models/_compat.py +++ b/libs/partners/openai/langchain_openai/chat_models/_compat.py @@ -74,6 +74,17 @@ from typing import Any, cast from langchain_core.messages import AIMessage, is_data_content_block from langchain_core.messages import content as types + +def _unwrap_non_standard(block: dict) -> dict: + """Unwrap a provider-native dictionary from a standard content block.""" + if block.get("type") == "non_standard" and isinstance( + value := block.get("value"), + dict, + ): + return value + return block + + _FUNCTION_CALL_IDS_MAP_KEY = "__openai_function_call_ids__" diff --git a/libs/partners/openai/langchain_openai/chat_models/base.py b/libs/partners/openai/langchain_openai/chat_models/base.py index 77de14e3ab..b74605ff70 100644 --- a/libs/partners/openai/langchain_openai/chat_models/base.py +++ b/libs/partners/openai/langchain_openai/chat_models/base.py @@ -163,6 +163,7 @@ from langchain_openai.chat_models._compat import ( _convert_from_v1_to_chat_completions, _convert_from_v1_to_responses, _convert_to_v03_ai_message, + _unwrap_non_standard, ) from langchain_openai.data._profiles import _PROFILES @@ -326,12 +327,79 @@ def _sanitize_chat_completions_content(content: str | list[dict]) -> str | list[ return content +_ADDITIONAL_TOOLS_BLOCK_TYPE = "additional_tools" +"""Responses API input item that adds tools partway through a conversation.""" + + +def _is_ai_role(role: str | None) -> bool: + """Return whether a message's role is the assistant's. + + Assistant content is replayed model output, not a caller's instruction, so it is + exempt from the placement checks a caller's own blocks are held to. + """ + return str(role).lower().startswith("ai") + + +def _is_system_role(role: str | None) -> bool: + """Return whether a message's role carries provider instructions. + + `SystemMessage` reports `"system"` whether or not it is later emitted with + OpenAI's `developer` role, so one check covers both spellings. + """ + return role in ("system", "developer") + + +def _raise_if_additional_tools(content: Any, reason: str) -> None: + """Reject an `additional_tools` block that cannot work where it was placed. + + The block only reaches the wire as a Responses top-level input item carried on + a system message. Anywhere else it is this provider's own block type in a + position this provider forbids, which the error taxonomy makes loud rather than + silent: nothing routes a request to the Responses API based on message content, + so a silent drop would make the broken case the default outcome. + + Args: + content: The message's content. + reason: Sentence explaining why this placement cannot work, and how to fix + it. Appended to the error. + + Raises: + ValueError: If an `additional_tools` block is present, in either spelling. + """ + if not isinstance(content, list): + return + for raw_block in content: + if ( + isinstance(raw_block, dict) + and _unwrap_non_standard(raw_block).get("type") + == _ADDITIONAL_TOOLS_BLOCK_TYPE + ): + msg = f"`additional_tools` {reason}" + raise ValueError(msg) + + def _format_message_content( content: Any, api: Literal["chat/completions", "responses"] = "chat/completions", role: str | None = None, ) -> Any: """Format message content.""" + if _is_ai_role(role): + # Replayed assistant output; `additional_tools` is also an output item, so + # an echoed one must survive a round trip rather than abort the request. + pass + elif not _is_system_role(role): + _raise_if_additional_tools( + content, + "must be carried on a `SystemMessage`. OpenAI restricts the input item " + 'to `role: "developer"`, so it cannot be sent on any other message.', + ) + elif api == "chat/completions": + _raise_if_additional_tools( + content, + "requires the Responses API and cannot be sent via Chat Completions. " + "Set `use_responses_api=True`.", + ) if content and isinstance(content, list): formatted_content = [] for block in content: @@ -3135,6 +3203,39 @@ class ChatOpenAI(BaseChatOpenAI): # type: ignore[override] See `bind_tools` for more. + ??? info "Mid-conversation tool additions" + + ```python + from langchain_core.messages import HumanMessage, SystemMessage + from langchain_openai import ChatOpenAI + + model = ChatOpenAI(model="gpt-6-astra", use_responses_api=True) + model.invoke( + [ + HumanMessage("What time is it?"), + SystemMessage( + [ + { + "type": "additional_tools", + "role": "developer", + "tools": [ + { + "type": "function", + "name": "get_time", + "description": "Get the current time.", + "parameters": { + "type": "object", + "properties": {}, + }, + } + ], + } + ] + ), + ] + ) + ``` + ??? info "Built-in (server-side) tools" You can access [built-in tools](https://platform.openai.com/docs/guides/tools?api-mode=responses) @@ -4993,6 +5094,21 @@ def _construct_responses_api_input( new_blocks.append(block) elif block["type"] in non_message_item_types: input_.append(block) + elif block["type"] == _ADDITIONAL_TOOLS_BLOCK_TYPE: + if isinstance(lc_msg, SystemMessage): + input_.append(block) + elif _is_system_role(msg["role"]): + # System content is a closed set here, so an unrecognized + # block is a mistake rather than something to forward. + # User content keeps its long-standing silent drop, where + # the set is open and warning would be noise. + warnings.warn( + f"Content block {block['type']!r} was dropped from a " + "system message: the Responses API has no input item " + "of that type, so it cannot be placed in the request.", + UserWarning, + stacklevel=2, + ) else: pass msg["content"] = new_blocks diff --git a/libs/partners/openai/tests/cassettes/test_system_additional_tools.yaml.gz b/libs/partners/openai/tests/cassettes/test_system_additional_tools.yaml.gz new file mode 100644 index 0000000000..d2d0203cb9 Binary files /dev/null and b/libs/partners/openai/tests/cassettes/test_system_additional_tools.yaml.gz differ diff --git a/libs/partners/openai/tests/integration_tests/chat_models/test_responses_api.py b/libs/partners/openai/tests/integration_tests/chat_models/test_responses_api.py index 7bfda617cf..aeff69b467 100644 --- a/libs/partners/openai/tests/integration_tests/chat_models/test_responses_api.py +++ b/libs/partners/openai/tests/integration_tests/chat_models/test_responses_api.py @@ -21,6 +21,7 @@ from langchain_core.messages import ( BaseMessageChunk, HumanMessage, MessageLikeRepresentation, + SystemMessage, ToolMessage, ) from langchain_core.tools import tool @@ -1960,3 +1961,34 @@ def test_reasoning_text_v1_v2_parity() -> None: # v2 bridge's default `"stop"` synthesis; provider metadata now # passes through unchanged.) assert v1.response_metadata == v2.response_metadata + + +@pytest.mark.vcr +def test_system_additional_tools() -> None: + model = ChatOpenAI(model="gpt-6-astra", use_responses_api=True) + response = model.invoke( + [ + HumanMessage("What time is it?"), + SystemMessage( + [ + { + "type": "additional_tools", + "role": "developer", + "tools": [ + { + "type": "function", + "name": "get_time", + "description": "Get the current time.", + "parameters": { + "type": "object", + "properties": {}, + }, + } + ], + } + ] + ), + ] + ) + assert isinstance(response, AIMessage) + assert response.tool_calls[0]["name"] == "get_time" diff --git a/libs/partners/openai/tests/unit_tests/chat_models/test_base.py b/libs/partners/openai/tests/unit_tests/chat_models/test_base.py index 80f272bd3a..3a00101b4c 100644 --- a/libs/partners/openai/tests/unit_tests/chat_models/test_base.py +++ b/libs/partners/openai/tests/unit_tests/chat_models/test_base.py @@ -2,6 +2,7 @@ from __future__ import annotations +import copy import json import warnings from functools import partial @@ -5507,6 +5508,223 @@ def test_langsmith_gateway_provider_base_url_uses_provider_key( assert llm.openai_api_key.get_secret_value() == "provider-key" +_ADDITIONAL_TOOLS_BLOCK = { + "type": "additional_tools", + "role": "developer", + "tools": [ + { + "type": "function", + "name": "get_customer", + "description": "Look up a customer by ID.", + "parameters": { + "type": "object", + "properties": {"customer_id": {"type": "string"}}, + "required": ["customer_id"], + "additionalProperties": False, + }, + } + ], +} +_FOREIGN_TOOL_CHANGE_BLOCK = { + "type": "tool_removal", + "tool": {"type": "tool_reference", "name": "get_weather"}, +} + + +@pytest.mark.parametrize("spelling", ["bare", "non_standard"]) +def test_additional_tools_block_becomes_input_item(spelling: str) -> None: + """An `additional_tools` block is hoisted to a top-level Responses input item.""" + block: dict = ( + _ADDITIONAL_TOOLS_BLOCK + if spelling == "bare" + else {"type": "non_standard", "value": _ADDITIONAL_TOOLS_BLOCK} + ) + llm = ChatOpenAI(model=OPENAI_TEST_MODEL, use_responses_api=True) + payload = llm._get_request_payload( + [ + HumanMessage("Earlier question"), + AIMessage("Earlier answer", response_metadata={"id": "resp_123"}), + SystemMessage([{"type": "text", "text": "Be concise."}, block]), + HumanMessage("Next question"), + ] + ) + + # The item precedes the message it was carried on, matching how the Responses + # API converter hoists every other non-message input item. + assert payload["input"][2] == _ADDITIONAL_TOOLS_BLOCK + assert payload["input"][3] == { + "role": "system", + "content": [{"type": "input_text", "text": "Be concise."}], + "type": "message", + } + assert payload["input"][4]["role"] == "user" + + +def test_additional_tools_block_empties_message() -> None: + """A system message carrying only the block leaves no message behind.""" + llm = ChatOpenAI(model=OPENAI_TEST_MODEL, use_responses_api=True) + payload = llm._get_request_payload( + [ + HumanMessage("Earlier question"), + SystemMessage([_ADDITIONAL_TOOLS_BLOCK]), + ] + ) + + assert payload["input"] == [ + {"role": "user", "content": "Earlier question", "type": "message"}, + _ADDITIONAL_TOOLS_BLOCK, + ] + + +def test_additional_tools_block_does_not_mutate_input_content() -> None: + """Hoisting the item must leave the caller's own message content untouched.""" + content: list[str | dict] = [ + {"type": "text", "text": "Be concise."}, + _ADDITIONAL_TOOLS_BLOCK, + ] + before = copy.deepcopy(content) + llm = ChatOpenAI(model=OPENAI_TEST_MODEL, use_responses_api=True) + llm._get_request_payload([HumanMessage("Earlier question"), SystemMessage(content)]) + assert content == before + + +@pytest.mark.parametrize("spelling", ["bare", "non_standard"]) +def test_additional_tools_block_on_chat_completions_raises(spelling: str) -> None: + """`additional_tools` requires the Responses API.""" + block: dict = ( + _ADDITIONAL_TOOLS_BLOCK + if spelling == "bare" + else {"type": "non_standard", "value": _ADDITIONAL_TOOLS_BLOCK} + ) + llm = ChatOpenAI(model=OPENAI_TEST_MODEL) + with pytest.raises(ValueError, match="use_responses_api=True"): + llm._get_request_payload( + [HumanMessage("Earlier question"), SystemMessage([block])] + ) + + +@pytest.mark.parametrize("spelling", ["bare", "non_standard"]) +@pytest.mark.parametrize("use_responses_api", [True, False]) +@pytest.mark.parametrize("message_type", ["human", "tool"]) +def test_additional_tools_block_off_system_message_raises( + message_type: str, + use_responses_api: bool, + spelling: str, +) -> None: + """OpenAI restricts the input item to `role: "developer"`. + + Anywhere but a `SystemMessage` it is this provider's own block in a position + this provider forbids, so it is raised rather than dropped. Guards against + client-supplied content blocks reaching the top-level input list. + """ + block: dict = ( + _ADDITIONAL_TOOLS_BLOCK + if spelling == "bare" + else {"type": "non_standard", "value": _ADDITIONAL_TOOLS_BLOCK} + ) + message: BaseMessage = ( + HumanMessage([block]) + if message_type == "human" + else ToolMessage([block], tool_call_id="call_1") + ) + llm = ChatOpenAI(model=OPENAI_TEST_MODEL, use_responses_api=use_responses_api) + with pytest.raises(ValueError, match="SystemMessage"): + llm._get_request_payload([message]) + + +def test_additional_tools_block_on_ai_message_not_rejected() -> None: + """`additional_tools` is also a Responses *output* item. + + Replaying an assistant turn that echoes one must not raise; handling the output + form is out of scope, and out of scope should mean untouched, not fatal. + """ + llm = ChatOpenAI(model=OPENAI_TEST_MODEL, use_responses_api=True) + llm._get_request_payload( + [ + HumanMessage("Earlier question"), + AIMessage( + [{"type": "text", "text": "Sure."}, _ADDITIONAL_TOOLS_BLOCK], + response_metadata={"id": "resp_123"}, + ), + ] + ) + + +@pytest.mark.parametrize("spelling", ["bare", "non_standard"]) +def test_unrecognized_system_block_dropped_with_warning(spelling: str) -> None: + """Responses system content is a closed set, so an unknown block is reported.""" + block: dict = ( + _FOREIGN_TOOL_CHANGE_BLOCK + if spelling == "bare" + else {"type": "non_standard", "value": _FOREIGN_TOOL_CHANGE_BLOCK} + ) + llm = ChatOpenAI(model=OPENAI_TEST_MODEL, use_responses_api=True) + with pytest.warns(UserWarning, match="tool_removal"): + payload = llm._get_request_payload( + [ + HumanMessage("Earlier question"), + SystemMessage([{"type": "text", "text": "Be concise."}, block]), + ] + ) + + assert payload["input"][-1] == { + "role": "system", + "content": [{"type": "input_text", "text": "Be concise."}], + "type": "message", + } + + +def test_unrecognized_user_block_dropped_silently() -> None: + """User content is an open set, so dropping an unknown block stays quiet.""" + llm = ChatOpenAI(model=OPENAI_TEST_MODEL, use_responses_api=True) + with warnings.catch_warnings(): + warnings.simplefilter("error") + payload = llm._get_request_payload( + [ + HumanMessage( + [ + {"type": "text", "text": "Hello"}, + {"type": "made_up_block", "foo": "bar"}, + ] + ) + ] + ) + + assert payload["input"] == [ + { + "role": "user", + "content": [{"type": "input_text", "text": "Hello"}], + "type": "message", + }, + ] + + +@pytest.mark.parametrize("role", ["system", "human"]) +def test_unrecognized_block_forwarded_on_chat_completions(role: str) -> None: + """Chat Completions keeps its long-standing passthrough for unknown blocks. + + `additional_tools` is the one system block it rejects, as that's a common mistake + (needs responses api). Every other unknown block is passed through as the caller + wrote it. + """ + message = ( + SystemMessage([_FOREIGN_TOOL_CHANGE_BLOCK]) + if role == "system" + else HumanMessage([_FOREIGN_TOOL_CHANGE_BLOCK]) + ) + llm = ChatOpenAI(model=OPENAI_TEST_MODEL) + with warnings.catch_warnings(): + warnings.simplefilter("error") + payload = llm._get_request_payload([message]) + + assert payload["messages"] == [ + { + "role": role if role == "system" else "user", + "content": [_FOREIGN_TOOL_CHANGE_BLOCK], + } + ] + + def test_configuration_update_block_becomes_input_item() -> None: """A `configuration_update` block is hoisted out of the message content.