mirror of
https://github.com/langchain-ai/langchain.git
synced 2026-10-05 09:25:14 +03:00
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:
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"
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user