fix: suppress pydantic serializer warnings leaking to the terminal

The Anthropic SDK's streaming accumulator builds ParsedMessage snapshots
whose ParsedTextBlock content doesn't match the generic union pydantic
expects, so model_dump() on stream events (message_stop) emits
PydanticSerializationUnexpectedValue UserWarnings straight into the
user's CLI output mid-response.

Pass warnings=False at every helper that dumps arbitrary SDK models
(relay_llm/_jsonable, relay_tools/_jsonable, anthropic_adapter
_to_plain_data, run_agent _hook_jsonable, chat_completion_helpers
extra_content/reasoning_details sites, chat_completions transport),
with a TypeError fallback for duck-typed model_dump implementations.

Adds regression tests including a precondition test that proves the
fixture still trips the warning without suppression.
This commit is contained in:
Teknium 2026-08-08 23:00:10 -07:00
parent 1d45e62f30
commit ceebb21dd7
7 changed files with 190 additions and 9 deletions

View File

@ -1867,7 +1867,16 @@ def _to_plain_data(value: Any, *, _depth: int = 0, _path: Optional[set] = None)
if hasattr(value, "model_dump"):
_path.add(obj_id)
result = _to_plain_data(value.model_dump(), _depth=_depth + 1, _path=_path)
try:
# warnings=False: content blocks from the streaming accumulator
# (ParsedTextBlock et al.) trip pydantic's serializer-mismatch
# UserWarning against the generic Message union; the dump itself
# is correct, and the warning leaks to the user's terminal.
dumped = value.model_dump(warnings=False)
except TypeError:
# Duck-typed model_dump without pydantic's signature.
dumped = value.model_dump()
result = _to_plain_data(dumped, _depth=_depth + 1, _path=_path)
_path.discard(obj_id)
return result
if isinstance(value, dict):

View File

@ -1755,7 +1755,12 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic
elif hasattr(d, "__dict__"):
preserved.append(d.__dict__)
elif hasattr(d, "model_dump"):
preserved.append(d.model_dump())
try:
# warnings=False: avoid pydantic serializer UserWarnings
# on generic-union SDK models leaking to the terminal.
preserved.append(d.model_dump(warnings=False))
except TypeError:
preserved.append(d.model_dump())
if preserved:
msg["reasoning_details"] = preserved
@ -1845,7 +1850,10 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic
extra = getattr(tool_call, "extra_content", None)
if extra is not None:
if hasattr(extra, "model_dump"):
extra = extra.model_dump()
try:
extra = extra.model_dump(warnings=False)
except TypeError:
extra = extra.model_dump()
tc_dict["extra_content"] = extra
tool_calls.append(tc_dict)
msg["tool_calls"] = tool_calls
@ -3538,7 +3546,10 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
extra = (tc_delta.model_extra if isinstance(tc_delta.model_extra, dict) else {}).get("extra_content")
if extra is not None:
if hasattr(extra, "model_dump"):
extra = extra.model_dump()
try:
extra = extra.model_dump(warnings=False)
except TypeError:
extra = extra.model_dump()
entry["extra_content"] = extra
# Fire once per tool when the full name is available
name = entry["function"]["name"]

View File

@ -1194,7 +1194,15 @@ def _jsonable(value: Any) -> Any:
model_dump = getattr(type(value), "model_dump", None)
if callable(model_dump):
try:
return _jsonable(value.model_dump(mode="json"))
# warnings=False: SDK stream events (e.g. the Anthropic
# ParsedMessage inside message_stop) carry generic-union content
# blocks that pydantic serializes fine but warns about — and the
# warning leaks to the user's terminal mid-response (#82xxx).
try:
return _jsonable(value.model_dump(mode="json", warnings=False))
except TypeError:
# Duck-typed model_dump without pydantic's signature.
return _jsonable(value.model_dump())
except Exception:
pass
try:

View File

@ -93,7 +93,12 @@ def _jsonable(value: Any) -> Any:
model_dump = getattr(value, "model_dump", None)
if callable(model_dump):
try:
return _jsonable(model_dump(mode="json"))
# warnings=False: suppress pydantic's serializer UserWarnings on
# generic-union SDK models; they would leak to the CLI mid-turn.
try:
return _jsonable(model_dump(mode="json", warnings=False))
except TypeError:
return _jsonable(model_dump())
except Exception:
pass
try:

View File

@ -778,7 +778,12 @@ class ChatCompletionsTransport(ProviderTransport):
if extra is not None:
if hasattr(extra, "model_dump"):
try:
extra = extra.model_dump()
extra = extra.model_dump(warnings=False)
except TypeError:
try:
extra = extra.model_dump()
except Exception:
pass
except Exception:
pass
tc_provider_data["extra_content"] = extra

View File

@ -2768,9 +2768,15 @@ class AIAgent:
try:
if hasattr(value, "model_dump"):
try:
dumped = value.model_dump(mode="json")
# warnings=False: pydantic's serializer UserWarnings on
# generic-union SDK models (Anthropic ParsedMessage etc.)
# would otherwise leak to the terminal mid-response.
dumped = value.model_dump(mode="json", warnings=False)
except TypeError:
dumped = value.model_dump()
try:
dumped = value.model_dump(mode="json")
except TypeError:
dumped = value.model_dump()
return cls._hook_jsonable(
dumped,
depth=depth + 1,

View File

@ -0,0 +1,137 @@
"""Serializer-warning leak regression tests (#82xxx).
The Anthropic SDK's streaming accumulator builds ``ParsedMessage`` snapshots
whose content blocks (``ParsedTextBlock``) don't match the generic union
pydantic expects at serialization time. ``model_dump()`` on those objects
emits ``PydanticSerializationUnexpectedValue`` UserWarnings that leak
straight into the user's terminal mid-response.
Every Hermes serialization helper that dumps arbitrary SDK models must pass
``warnings=False`` (with a TypeError fallback for duck-typed models). These
tests pin that contract for all four helpers.
"""
import warnings
import pytest
anthropic = pytest.importorskip("anthropic")
from anthropic._models import build
from anthropic.lib.streaming._messages import accumulate_event
from anthropic.lib.streaming._types import ParsedMessageStopEvent
from anthropic.types import (
Message,
RawContentBlockDeltaEvent,
RawContentBlockStartEvent,
RawMessageStartEvent,
Usage,
)
def _accumulated_stop_event():
"""Build a message_stop event exactly the way the SDK stream does."""
start = RawMessageStartEvent(
type="message_start",
message=Message(
id="msg_1",
content=[],
model="claude-x",
role="assistant",
stop_reason=None,
stop_sequence=None,
type="message",
usage=Usage(input_tokens=1, output_tokens=0),
),
)
cb_start = RawContentBlockStartEvent(
type="content_block_start", index=0, content_block={"type": "text", "text": ""}
)
cb_delta = RawContentBlockDeltaEvent(
type="content_block_delta",
index=0,
delta={"type": "text_delta", "text": "hello"},
)
snapshot = None
for event in (start, cb_start, cb_delta):
snapshot = accumulate_event(event=event, current_snapshot=snapshot)
return build(ParsedMessageStopEvent, type="message_stop", message=snapshot), snapshot
def _pydantic_warnings(recorded):
return [w for w in recorded if "Pydantic serializer warnings" in str(w.message)]
def test_stop_event_dump_actually_warns_without_suppression():
"""Precondition: the fixture really trips the SDK/pydantic warning.
If a future SDK/pydantic release stops warning here, the other tests
pass vacuously this test tells us the guard can be simplified.
"""
stop_event, _ = _accumulated_stop_event()
with warnings.catch_warnings(record=True) as recorded:
warnings.simplefilter("always")
stop_event.model_dump()
assert _pydantic_warnings(recorded), (
"fixture no longer reproduces the pydantic serializer warning; "
"the warnings=False guards may be removable"
)
def test_relay_llm_jsonable_no_warning_leak():
from agent.relay_llm import _jsonable
stop_event, _ = _accumulated_stop_event()
with warnings.catch_warnings(record=True) as recorded:
warnings.simplefilter("always")
payload = _jsonable(stop_event)
assert not _pydantic_warnings(recorded)
assert payload["message"]["content"][0]["text"] == "hello"
def test_relay_tools_jsonable_no_warning_leak():
from agent.relay_tools import _jsonable
stop_event, _ = _accumulated_stop_event()
with warnings.catch_warnings(record=True) as recorded:
warnings.simplefilter("always")
payload = _jsonable(stop_event)
assert not _pydantic_warnings(recorded)
assert payload["message"]["content"][0]["text"] == "hello"
def test_anthropic_to_plain_data_no_warning_leak():
from agent.anthropic_adapter import _to_plain_data
_, snapshot = _accumulated_stop_event()
with warnings.catch_warnings(record=True) as recorded:
warnings.simplefilter("always")
payload = _to_plain_data(snapshot)
assert not _pydantic_warnings(recorded)
assert payload["content"][0]["text"] == "hello"
def test_hook_jsonable_no_warning_leak():
from run_agent import AIAgent
stop_event, _ = _accumulated_stop_event()
with warnings.catch_warnings(record=True) as recorded:
warnings.simplefilter("always")
payload = AIAgent._hook_jsonable(stop_event)
assert not _pydantic_warnings(recorded)
assert payload["message"]["content"][0]["text"] == "hello"
def test_duck_typed_model_dump_fallback():
"""Non-pydantic objects with a bare model_dump() must still serialize."""
from agent.relay_llm import _jsonable as rl_jsonable
from agent.relay_tools import _jsonable as rt_jsonable
from agent.anthropic_adapter import _to_plain_data
class Duck:
def model_dump(self): # no mode/warnings kwargs
return {"quack": True}
assert rl_jsonable(Duck()) == {"quack": True}
assert rt_jsonable(Duck()) == {"quack": True}
assert _to_plain_data(Duck()) == {"quack": True}