From 4f22b1ef455e523ecdd2126607599ef5a48d35ec Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 17 Jun 2026 15:23:46 -0700 Subject: [PATCH] Fix the exclude_if on model_input even if the model is deep copied --- src/ai/types/messages.py | 8 ++++++-- tests/types/test_messages.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/ai/types/messages.py b/src/ai/types/messages.py index 8a5fbaf..831bc3d 100644 --- a/src/ai/types/messages.py +++ b/src/ai/types/messages.py @@ -139,7 +139,11 @@ class MessageBundle(pydantic.BaseModel): ) -_MODEL_INPUT_UNSET: Any = object() +class _ModelInputUnset: + pass + + +_MODEL_INPUT_UNSET: Any = _ModelInputUnset() # Coarse tag for the shape of ``ToolResultPart.result``. # ``"special"`` means a :class:`SpecialToolResult`; ``"error"`` flags @@ -174,7 +178,7 @@ class ToolResultPart(pydantic.BaseModel): # again, though. model_input: Any = pydantic.Field( default_factory=lambda: _MODEL_INPUT_UNSET, - exclude_if=lambda v: v is _MODEL_INPUT_UNSET, + exclude_if=lambda v: isinstance(v, _ModelInputUnset), repr=False, ) diff --git a/tests/types/test_messages.py b/tests/types/test_messages.py index b8847cc..937916d 100644 --- a/tests/types/test_messages.py +++ b/tests/types/test_messages.py @@ -283,3 +283,35 @@ def test_tool_result_file_part_base64_valid_after_round_trip() -> None: assert "-" not in b64 decoded = base64.b64decode(b64) assert decoded == raw + + +def test_tool_result_without_model_input_serializes_after_deep_copy() -> None: + """A deep-copied ToolResultPart with no model_input still serializes. + + ``model_input`` defaults to the ``_MODEL_INPUT_UNSET`` singleton and is + dropped from output via ``exclude_if=lambda v: v is _MODEL_INPUT_UNSET``, + an identity check. ``model_copy(deep=True)`` rebuilds the sentinel into a + *new* instance, so the identity check fails, the field is no longer + excluded, and pydantic tries to serialize the bare sentinel. Client apps + that deep-copy messages hit this on serialize. + """ + msg = messages.Message( + role="tool", + parts=[ + messages.ToolResultPart( + tool_call_id="tc", tool_name="t", result={"ok": 1} + ) + ], + ) + part = msg.parts[0] + assert isinstance(part, messages.ToolResultPart) + assert not part.has_model_input + + cloned = msg.model_copy(deep=True) + + j = cloned.model_dump_json() + restored = messages.Message.model_validate_json(j) + rpart = restored.parts[0] + assert isinstance(rpart, messages.ToolResultPart) + assert rpart.result == {"ok": 1} + assert not rpart.has_model_input