diff --git a/litellm/llms/anthropic/messages/adapters/handler.py b/litellm/llms/anthropic/messages/adapters/handler.py index 71ccfbaf915..66b206b528a 100644 --- a/litellm/llms/anthropic/messages/adapters/handler.py +++ b/litellm/llms/anthropic/messages/adapters/handler.py @@ -163,17 +163,17 @@ async def prepare_context_managed_request( ) # Skip the client-history pre-processing when a ``compact_20260112`` - # polyfill spec will run: that editor already slices around any client-sent + # polyfill edit runs first: that editor already slices around any client-sent # compaction block in its Phase A (and uses the full post-compaction tail # for its token-threshold check). Pre-collapsing to just the latest user # question here would starve the polyfill of conversation context and # silently drop intermediate turns. - polyfill_will_run: Final = _polyfill_will_run( + compact_runs_first: Final = _compact_runs_first( context_management_spec=context_management_spec, additional_drop_params=additional_drop_params, ) - if polyfill_will_run: + if compact_runs_first: history_result: PolyfillResult | None = None working_messages: _AnthropicMessages = messages working_system: _AnthropicSystem = system @@ -211,7 +211,7 @@ async def prepare_context_managed_request( # ``_run_polyfill_if_enabled``), apply the slice-only fallback now so # Anthropic-specific ``compaction`` content blocks don't leak through # to non-Anthropic backends that would reject them. - if polyfill_will_run and history_result is None: + if compact_runs_first and history_result is None: history_result = apply_client_compaction_block_history( messages=messages, system=system, @@ -219,18 +219,18 @@ async def prepare_context_managed_request( return history_result -def _polyfill_will_run( +def _compact_runs_first( *, context_management_spec: _ContextManagementSpec, additional_drop_params: list[str] | None, ) -> bool: - """Return True when ``compact_20260112`` will run via the polyfill dispatcher. + """Return True when ``compact_20260112`` is the first edit the polyfill dispatcher runs. Mirrors the gating in ``_run_polyfill_if_enabled``: an empty spec or an explicit ``context_management`` entry in ``additional_drop_params`` - short-circuits the polyfill. The pre-processing skip only applies when the - dispatcher will actually invoke ``apply_compact_20260112`` (which has its - own compaction-block slicing). + short-circuits the polyfill. The pre-processing skip only applies when + ``apply_compact_20260112`` (which has its own compaction-block slicing) + sees the messages before any other editor does. """ edits: Final = _normalize_spec_edits( context_management_spec=context_management_spec, @@ -243,7 +243,7 @@ def _polyfill_will_run( COMPACT_EDIT_TYPE, ) - return any(edit.get("type") == COMPACT_EDIT_TYPE for edit in edits) + return bool(edits) and edits[0].get("type") == COMPACT_EDIT_TYPE def _spec_has_non_compact_edits( diff --git a/tests/unit/llms/anthropic/messages/context_management/test_clear_tool_uses.py b/tests/unit/llms/anthropic/messages/context_management/test_clear_tool_uses.py index 38b60e716fb..557fd49347a 100644 --- a/tests/unit/llms/anthropic/messages/context_management/test_clear_tool_uses.py +++ b/tests/unit/llms/anthropic/messages/context_management/test_clear_tool_uses.py @@ -724,8 +724,9 @@ def test_input_tokens_trigger_counts_the_system_prompt(system: str | list[dict[s assert with_system["cleared_input_tokens"] == met_without_system["cleared_input_tokens"] > 0 -async def test_input_tokens_trigger_counts_a_client_compaction_summary() -> None: - """A client compaction summary moves into the system prompt, so the trigger counts it too.""" +@pytest.mark.parametrize("compact_position", [None, "after", "before"], ids=["alone", "before_compact", "after_compact"]) +async def test_input_tokens_trigger_counts_a_client_compaction_summary(compact_position: str | None) -> None: + """A client compaction summary moves into the system prompt, so the trigger counts it too, in any edit order.""" tail: Final = _make_history(n_pairs=3) edit_spec: Final = { "type": "clear_tool_uses_20250919", @@ -733,6 +734,10 @@ async def test_input_tokens_trigger_counts_a_client_compaction_summary() -> None "keep": {"type": "tool_uses", "value": 1}, } compacted_turn: Final = {"role": "assistant", "content": [{"type": "compaction", "content": "Earlier work. " * 400}]} + compact_spec: Final = {"type": "compact_20260112", "trigger": {"type": "input_tokens", "value": 50_000}} + edits: Final = {None: [edit_spec], "after": [edit_spec, compact_spec], "before": [compact_spec, edit_spec]}[ + compact_position + ] async def prepare(messages: list[dict[str, object]]) -> PolyfillResult | None: return await prepare_context_managed_request( @@ -740,18 +745,21 @@ async def test_input_tokens_trigger_counts_a_client_compaction_summary() -> None messages=messages, tools=None, system=None, - context_management_spec={"edits": [edit_spec]}, + context_management_spec={"edits": edits}, litellm_metadata=None, additional_drop_params=None, llm_router=None, ) + def cleared(result: PolyfillResult | None) -> list[object]: + applied: Final = result.applied_edits if result is not None else [] + return [edit.get("cleared_tool_uses") for edit in applied if edit.get("type") == "clear_tool_uses_20250919"] + without_summary: Final = await prepare(deepcopy(tail)) with_summary: Final = await prepare([compacted_turn, *deepcopy(tail)]) - assert without_summary is None or not without_summary.applied_edits - assert with_summary is not None - assert [edit.get("cleared_tool_uses") for edit in with_summary.applied_edits] == [2] + assert cleared(without_summary) == [] + assert cleared(with_summary) == [2] @pytest.mark.parametrize(("clear_tool_inputs", "triggers"), [(False, False), (True, True)])