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:
parent
1d45e62f30
commit
ceebb21dd7
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
10
run_agent.py
10
run_agent.py
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
Loading…
Reference in New Issue