From b99719f2a677ae0efef8223b10214ff05f3cd0ca Mon Sep 17 00:00:00 2001 From: lllakshit Date: Wed, 19 Aug 2026 01:41:09 +0530 Subject: [PATCH] fix(beta): omit compaction encrypted_content on request round-trip The Messages API rejects encrypted_content on request compaction blocks even though response blocks include it. Strip the field during request serialization so messages.append(response.content) works without a 400. Fixes #1828 --- src/anthropic/_utils/_transform.py | 5 +- src/anthropic/lib/_compaction.py | 56 ++++ .../types/beta/beta_compaction_block.py | 7 +- .../types/beta/beta_compaction_block_param.py | 7 +- tests/lib/test_compaction_request.py | 304 ++++++++++++++++++ 5 files changed, 375 insertions(+), 4 deletions(-) create mode 100644 src/anthropic/lib/_compaction.py create mode 100644 tests/lib/test_compaction_request.py diff --git a/src/anthropic/_utils/_transform.py b/src/anthropic/_utils/_transform.py index 1331da174..0fd29191d 100644 --- a/src/anthropic/_utils/_transform.py +++ b/src/anthropic/_utils/_transform.py @@ -30,6 +30,7 @@ is_annotated_type, strip_annotated_type, ) +from ..lib._compaction import omit_compaction_encrypted_content _T = TypeVar("_T") @@ -109,7 +110,7 @@ class Params(TypedDict, total=False): It should be noted that the transformations that this function does are not represented in the type system. """ transformed = _transform_recursive(data, annotation=cast(type, expected_type)) - return cast(_T, transformed) + return cast(_T, omit_compaction_encrypted_content(transformed)) @lru_cache(maxsize=8096) @@ -316,7 +317,7 @@ class Params(TypedDict, total=False): It should be noted that the transformations that this function does are not represented in the type system. """ transformed = await _async_transform_recursive(data, annotation=cast(type, expected_type)) - return cast(_T, transformed) + return cast(_T, omit_compaction_encrypted_content(transformed)) async def _async_transform_recursive( diff --git a/src/anthropic/lib/_compaction.py b/src/anthropic/lib/_compaction.py new file mode 100644 index 000000000..95aa53fc2 --- /dev/null +++ b/src/anthropic/lib/_compaction.py @@ -0,0 +1,56 @@ +"""Request-side handling of compaction content blocks. + +Response compaction blocks include ``encrypted_content``, but the Messages API +rejects that field on request blocks (``Extra inputs are not permitted``). The +documented round-trip is ``messages.append(response.content)``, so the SDK +omits ``encrypted_content`` from compaction blocks while serializing requests. + +This module is not Stainless-generated; keep request-only behavior here so it +survives OpenAPI regenerations of the TypedDicts. +""" + +from __future__ import annotations + +from typing import Any, TypeVar, cast + +_T = TypeVar("_T") + + +def omit_compaction_encrypted_content(data: _T) -> _T: + """Drop ``encrypted_content`` from compaction blocks in a request payload. + + Other block types that legitimately send ``encrypted_content`` (web search + results, advisor redacted results) are left unchanged. Objects that do not + contain a compaction block are returned unchanged so request transform + identity optimizations keep working. + """ + return cast(_T, _omit_compaction_encrypted_content(data)) + + +def _omit_compaction_encrypted_content(data: object) -> Any: + if isinstance(data, list): + items = cast("list[object]", data) + new_items: list[object] = [_omit_compaction_encrypted_content(item) for item in items] + if all(new is old for new, old in zip(new_items, items)): + return cast(Any, data) + return cast(Any, new_items) + if isinstance(data, tuple): + tuple_items = cast("tuple[object, ...]", data) + new_tuple = tuple(_omit_compaction_encrypted_content(item) for item in tuple_items) + if all(new is old for new, old in zip(new_tuple, tuple_items)): + return cast(Any, data) + return cast(Any, new_tuple) + if not isinstance(data, dict): + return data + + mapping = cast("dict[str, object]", data) + nested: dict[str, object] = {key: _omit_compaction_encrypted_content(value) for key, value in mapping.items()} + changed = any(nested[key] is not value for key, value in mapping.items()) + if mapping.get("type") == "compaction" and "encrypted_content" in mapping: + if not changed: + nested = dict(mapping) + nested.pop("encrypted_content", None) + return cast(Any, nested) + if changed: + return cast(Any, nested) + return cast(Any, data) diff --git a/src/anthropic/types/beta/beta_compaction_block.py b/src/anthropic/types/beta/beta_compaction_block.py index 53da79fa9..6921b3405 100644 --- a/src/anthropic/types/beta/beta_compaction_block.py +++ b/src/anthropic/types/beta/beta_compaction_block.py @@ -20,6 +20,11 @@ class BetaCompactionBlock(BaseModel): """Summary of compacted content, or null if compaction failed""" encrypted_content: Optional[str] = None - """Opaque metadata from prior compaction, to be round-tripped verbatim""" + """Opaque metadata returned on compaction response blocks. + + The Messages API rejects this field on request compaction blocks, so the SDK + omits it when serializing requests. Clients can still append ``response.content`` + verbatim; the field is dropped on the wire, not on the response object. + """ type: Literal["compaction"] diff --git a/src/anthropic/types/beta/beta_compaction_block_param.py b/src/anthropic/types/beta/beta_compaction_block_param.py index 50a5d4886..8af7814b9 100644 --- a/src/anthropic/types/beta/beta_compaction_block_param.py +++ b/src/anthropic/types/beta/beta_compaction_block_param.py @@ -29,4 +29,9 @@ class BetaCompactionBlockParam(TypedDict, total=False): """Summary of previously compacted content, or null if compaction failed""" encrypted_content: Optional[str] - """Opaque metadata from prior compaction, to be round-tripped verbatim""" + """Opaque metadata returned on compaction response blocks. + + The Messages API rejects this field on request compaction blocks, so the SDK + omits it when serializing requests. Prefer appending ``response.content`` + unmodified; do not rely on this field being sent. + """ diff --git a/tests/lib/test_compaction_request.py b/tests/lib/test_compaction_request.py new file mode 100644 index 000000000..0924a6d75 --- /dev/null +++ b/tests/lib/test_compaction_request.py @@ -0,0 +1,304 @@ +from __future__ import annotations + +import json +from typing import Any, TypeVar, cast + +import httpx +import respx +import pytest + +from anthropic import Anthropic, AsyncAnthropic +from anthropic._utils import transform as _transform, async_transform as _async_transform +from anthropic.types.beta import BetaCompactionBlock +from anthropic.lib._compaction import omit_compaction_encrypted_content +from anthropic._utils._transform import _transform_recursive +from anthropic.types.beta.message_create_params import MessageCreateParamsNonStreaming +from anthropic.types.beta.messages.batch_create_params import BatchCreateParams + +from ..conftest import base_url + +_T = TypeVar("_T") + +COMPACTION_PAYLOAD = "EpwBCioIDxgCEAEYASJALd_opaque_compaction_payload" + + +parametrize = pytest.mark.parametrize("use_async", [False, True], ids=["sync", "async"]) + + +async def transform(data: _T, expected_type: object, use_async: bool) -> _T: + if use_async: + return await _async_transform(data, expected_type=expected_type) + return _transform(data, expected_type=expected_type) + + +def _message_json() -> dict[str, object]: + return { + "id": "msg_abc123", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [{"type": "text", "text": "hi"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + } + + +def _compaction_dict() -> dict[str, str]: + return { + "type": "compaction", + "content": "Earlier conversation summarized.", + "encrypted_content": COMPACTION_PAYLOAD, + } + + +def test_omit_compaction_encrypted_content_leaves_other_blocks() -> None: + web_search = { + "type": "web_search_result", + "title": "Example", + "url": "https://example.com", + "encrypted_content": "keep-web-search", + } + advisor = { + "type": "advisor_redacted_result", + "encrypted_content": "keep-advisor", + } + assert omit_compaction_encrypted_content(web_search) is web_search + assert omit_compaction_encrypted_content(advisor) is advisor + ints = [1, 2, 3] + assert omit_compaction_encrypted_content(ints) is ints + + +def test_omit_compaction_encrypted_content_strips_only_compaction() -> None: + payload = { + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": [_compaction_dict()]}, + ] + } + stripped = omit_compaction_encrypted_content(payload) + block = stripped["messages"][1]["content"][0] + assert block == {"type": "compaction", "content": "Earlier conversation summarized."} + assert payload["messages"][1]["content"][0]["encrypted_content"] == COMPACTION_PAYLOAD + + +@parametrize +@pytest.mark.asyncio +async def test_transform_strips_compaction_dict_on_create_params(use_async: bool) -> None: + payload = { + "max_tokens": 16, + "model": "claude-sonnet-4-5", + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": [_compaction_dict()]}, + ], + } + # Stainless TypedDict transform still copies encrypted_content; the request + # wrapper is what drops it so documented response.content round-trips work. + raw = _transform_recursive(payload, annotation=MessageCreateParamsNonStreaming) + assert raw["messages"][1]["content"][0]["encrypted_content"] == COMPACTION_PAYLOAD + + body = await transform(payload, MessageCreateParamsNonStreaming, use_async) + block = body["messages"][1]["content"][0] + assert block == {"type": "compaction", "content": "Earlier conversation summarized."} + + +@parametrize +@pytest.mark.asyncio +async def test_transform_strips_compaction_model_round_trip(use_async: bool) -> None: + block = BetaCompactionBlock( + type="compaction", + content="Earlier conversation summarized.", + encrypted_content=COMPACTION_PAYLOAD, + ) + assert block.encrypted_content == COMPACTION_PAYLOAD + + body = await transform( + { + "max_tokens": 16, + "model": "claude-sonnet-4-5", + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": [block]}, + ], + }, + MessageCreateParamsNonStreaming, + use_async, + ) + sent = body["messages"][1]["content"][0] + assert sent == {"type": "compaction", "content": "Earlier conversation summarized."} + assert block.encrypted_content == COMPACTION_PAYLOAD + + +@parametrize +@pytest.mark.asyncio +async def test_transform_keeps_web_search_and_advisor_encrypted_content(use_async: bool) -> None: + body = await transform( + { + "max_tokens": 16, + "model": "claude-sonnet-4-5", + "messages": [ + { + "role": "user", + "content": [ + { + "type": "web_search_tool_result", + "tool_use_id": "srvtoolu_1", + "content": [ + { + "type": "web_search_result", + "title": "Example", + "url": "https://example.com", + "encrypted_content": "keep-web-search", + } + ], + }, + { + "type": "advisor_tool_result", + "tool_use_id": "toolu_1", + "content": { + "type": "advisor_redacted_result", + "encrypted_content": "keep-advisor", + }, + }, + ], + } + ], + }, + MessageCreateParamsNonStreaming, + use_async, + ) + content = body["messages"][0]["content"] + assert content[0]["content"][0]["encrypted_content"] == "keep-web-search" + assert content[1]["content"]["encrypted_content"] == "keep-advisor" + + +@parametrize +@pytest.mark.asyncio +async def test_transform_strips_compaction_inside_batch_params(use_async: bool) -> None: + body = await transform( + { + "requests": [ + { + "custom_id": "req-1", + "params": { + "max_tokens": 16, + "model": "claude-sonnet-4-5", + "messages": [ + { + "role": "assistant", + "content": [_compaction_dict()], + } + ], + }, + } + ] + }, + BatchCreateParams, + use_async, + ) + block = body["requests"][0]["params"]["messages"][0]["content"][0] + assert "encrypted_content" not in block + assert block["type"] == "compaction" + + +@parametrize +@pytest.mark.asyncio +async def test_transform_preserves_compaction_cache_control_and_null_content(use_async: bool) -> None: + body = await transform( + { + "max_tokens": 16, + "model": "claude-sonnet-4-5", + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "compaction", + "content": None, + "encrypted_content": COMPACTION_PAYLOAD, + "cache_control": {"type": "ephemeral"}, + } + ], + } + ], + }, + MessageCreateParamsNonStreaming, + use_async, + ) + block = body["messages"][0]["content"][0] + assert block["type"] == "compaction" + assert block["content"] is None + assert block["cache_control"] == {"type": "ephemeral"} + assert "encrypted_content" not in block + + +def _request_body(respx_mock: respx.MockRouter) -> dict[str, Any]: + return cast("dict[str, Any]", json.loads(respx_mock.calls.last.request.content)) + + +@pytest.mark.respx(base_url=base_url) +class TestCreateOmitsCompactionEncryptedContent: + def test_create_omits_field_when_round_tripping_response_block( + self, client: Anthropic, respx_mock: respx.MockRouter + ) -> None: + respx_mock.post("/v1/messages").mock(return_value=httpx.Response(200, json=_message_json())) + block = BetaCompactionBlock( + type="compaction", + content="Earlier conversation summarized.", + encrypted_content=COMPACTION_PAYLOAD, + ) + + client.beta.messages.create( + model="claude-sonnet-4-5", + max_tokens=16, + messages=[ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": [block]}, + ], + ) + + sent = _request_body(respx_mock)["messages"][1]["content"][0] + assert sent == {"type": "compaction", "content": "Earlier conversation summarized."} + assert block.encrypted_content == COMPACTION_PAYLOAD + + def test_create_omits_field_from_dict_history(self, client: Anthropic, respx_mock: respx.MockRouter) -> None: + respx_mock.post("/v1/messages").mock(return_value=httpx.Response(200, json=_message_json())) + + client.beta.messages.create( + model="claude-sonnet-4-5", + max_tokens=16, + messages=[ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": [_compaction_dict()]}, + ], + ) + + sent = _request_body(respx_mock)["messages"][1]["content"][0] + assert "encrypted_content" not in sent + assert sent["type"] == "compaction" + + +@pytest.mark.respx(base_url=base_url) +class TestAsyncCreateOmitsCompactionEncryptedContent: + async def test_async_create_omits_field_when_round_tripping_response_block( + self, async_client: AsyncAnthropic, respx_mock: respx.MockRouter + ) -> None: + respx_mock.post("/v1/messages").mock(return_value=httpx.Response(200, json=_message_json())) + block = BetaCompactionBlock( + type="compaction", + content="Earlier conversation summarized.", + encrypted_content=COMPACTION_PAYLOAD, + ) + + await async_client.beta.messages.create( + model="claude-sonnet-4-5", + max_tokens=16, + messages=[ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": [block]}, + ], + ) + + sent = _request_body(respx_mock)["messages"][1]["content"][0] + assert sent == {"type": "compaction", "content": "Earlier conversation summarized."}