feat(anthropic,openai): mid-conversation tool changes on SystemMessage (#40758)

Co-authored-by: ccurme <26529506+ccurme@users.noreply.github.com>
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
Co-authored-by: Chester Curme <chester.curme@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-09-23 10:38:51 -04:00
1 parent 4b65996406
commit 59baeb26d6
10 files changed
+984 -30

No files matched your search

@@ -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":
@@ -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"
@@ -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]
@@ -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.
@@ -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__"
@@ -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
@@ -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"
@@ -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.