diff --git a/src/anthropic/_compat.py b/src/anthropic/_compat.py index eee0bc4c7..6a2579902 100644 --- a/src/anthropic/_compat.py +++ b/src/anthropic/_compat.py @@ -120,6 +120,12 @@ def model_copy(model: _ModelT, *, deep: bool = False) -> _ModelT: return model.model_copy(deep=deep) +def model_fields_set(model: pydantic.BaseModel) -> set[str]: + if PYDANTIC_V1: + return model.__fields_set__ # type: ignore + return model.model_fields_set + + def model_json(model: pydantic.BaseModel, *, indent: int | None = None) -> str: if PYDANTIC_V1: return model.json(indent=indent) # type: ignore diff --git a/src/anthropic/lib/streaming/_beta_messages.py b/src/anthropic/lib/streaming/_beta_messages.py index c7d3d6c2c..a199c52e3 100644 --- a/src/anthropic/lib/streaming/_beta_messages.py +++ b/src/anthropic/lib/streaming/_beta_messages.py @@ -14,6 +14,7 @@ from ..._types import NotGiven, not_given from ..._utils import consume_sync_iterator, consume_async_iterator +from ..._compat import model_fields_set from ..._models import build, construct_type, construct_type_unchecked from ._beta_types import ( BetaCitationEvent, @@ -536,7 +537,8 @@ def accumulate_event( elif event.delta.type == "compaction_delta": if content.type == "compaction": content.content = event.delta.content - content.encrypted_content = event.delta.encrypted_content + if "encrypted_content" in model_fields_set(event.delta): + content.encrypted_content = event.delta.encrypted_content else: # we only want exhaustive checking for linters, not at runtime if TYPE_CHECKING: # type: ignore[unreachable] diff --git a/tests/lib/streaming/test_beta_messages.py b/tests/lib/streaming/test_beta_messages.py index fc2af491d..b49823ad9 100644 --- a/tests/lib/streaming/test_beta_messages.py +++ b/tests/lib/streaming/test_beta_messages.py @@ -569,6 +569,43 @@ def test_compaction(self, respx_mock: MockRouter) -> None: assert_compaction_response([event for event in stream], stream.get_final_message()) + @pytest.mark.respx(base_url=base_url) + def test_compaction_delta_without_encrypted_content_retains_metadata(self, respx_mock: MockRouter) -> None: + sse_lines = [ + "event: message_start\n", + 'data: {"type":"message_start","message":{"id":"msg_01","type":"message","role":"assistant","content":[],"model":"claude-opus-4-7","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":30,"output_tokens":1}}}\n\n', + "event: content_block_start\n", + 'data: {"type":"content_block_start","index":0,"content_block":{"type":"compaction","content":null,"encrypted_content":null}}\n\n', + "event: content_block_delta\n", + 'data: {"type":"content_block_delta","index":0,"delta":{"type":"compaction_delta","content":"Initial summary.","encrypted_content":"opaque_meta"}}\n\n', + "event: content_block_delta\n", + 'data: {"type":"content_block_delta","index":0,"delta":{"type":"compaction_delta","content":"Updated summary."}}\n\n', + "event: content_block_stop\n", + 'data: {"type":"content_block_stop","index":0}\n\n', + "event: message_delta\n", + 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":10}}\n\n', + "event: message_stop\n", + 'data: {"type":"message_stop"}\n\n', + ] + respx_mock.post("/v1/messages").mock( + return_value=httpx2.Response(200, content="".join(sse_lines).encode("utf-8")) + ) + + with sync_client.beta.messages.stream( + max_tokens=1024, + messages=[{"role": "user", "content": "Say hello there!"}], + model="claude-opus-4-7", + ) as stream: + events = list(stream) + final_message = stream.get_final_message() + + compaction_events = [e for e in events if isinstance(e, BetaCompactionEvent)] + assert len(compaction_events) == 2 + assert compaction_events[0].encrypted_content == "opaque_meta" + assert compaction_events[1].encrypted_content == "opaque_meta" + assert final_message.content[0].type == "compaction" + assert final_message.content[0].encrypted_content == "opaque_meta" + @pytest.mark.respx(base_url=base_url) def test_fallback_relabels_model(self, respx_mock: MockRouter) -> None: respx_mock.post("/v1/messages").mock(