fix(responses): place end-relative cache_control_injection_points on the bridged messages

A /v1/responses request bridged to chat completions ran the cache-control hook on a
provisional message list that holds only the input items with a role, so an index -1
point marked the user message and every function_call and function_call_output after
it went upstream unmarked. Negative ordinals now go to the bridge's pass with the
role-targeted points, where the final list ends in the caller's tool calls and results.

Backport to lts/1.108.x of the main fix on litellm_responses_bridge_negative_cache_index.
Cherry-picked from 583634dc69, with the conflict in the hook's module helpers resolved to add only this fix's two helpers.
This commit is contained in:
mateo-berri 2026-11-07 10:18:59 -08:00
parent 7e95523094
commit 722fe2ad7b
5 changed files with 101 additions and 20 deletions

View file

@ -333,15 +333,31 @@ def _may_carry_prompt_cache_breakpoint(message: Mapping[str, object]) -> bool:
# Set by a caller whose message list is not the one that goes upstream -- today the
# Responses API layer, whose `instructions` only becomes a system message further down.
# Tells this hook to hand role-targeted points to the pass holding the final messages
# rather than spending them on a list that is still missing some of their targets.
# Responses API layer, whose `instructions` only becomes a system message further down
# and whose tool calls and results only become messages there. Tells this hook to hand
# role-targeted and end-relative points to the pass holding the final messages rather
# than spending them on a list that is still missing some of their targets.
CARRY_UNMATCHED_MESSAGE_POINTS: Final = "_litellm_carry_unmatched_cache_control_points"
EXTERNAL_BREAKPOINTS_STAMP: Final = "_litellm_external_breakpoints"
CUSTOM_LLM_PROVIDER_STAMP: Final = "_litellm_custom_llm_provider"
def _message_point_ordinal(point: CacheControlMessageInjectionPoint) -> int | None:
index: Final = point.get("index")
if not isinstance(index, str):
return index
try:
return int(index)
except ValueError:
return None
def _placed_on_the_final_message_list(point: CacheControlMessageInjectionPoint) -> bool:
ordinal: Final = _message_point_ordinal(point)
return point.get("index") is None or (ordinal is not None and ordinal < 0)
class AnthropicCacheControlHook(CustomPromptManagement):
@staticmethod
def _request_value(request_kwargs: object, key: str) -> object:
@ -428,13 +444,17 @@ class AnthropicCacheControlHook(CustomPromptManagement):
# A provisional message list defers every role-targeted point to the pass holding
# the final one: a role with no message here may have one there, and settling all
# of them in one pass is what lets config order decide the shared breakpoint
# budget. An ordinal names a different message once a later layer builds its own
# list, so it is placed here or not at all.
# budget. A negative ordinal is deferred too, since the final list only grows at
# the front and its tail holds the tool calls and results missing here. A
# non-negative ordinal names a different message once a later layer prepends one,
# so it is placed here or not at all.
carried_message_points: Final[Sequence[CacheControlMessageInjectionPoint]] = (
tuple(point for point in message_points if point.get("index") is None) if carry_unmatched else ()
tuple(point for point in message_points if _placed_on_the_final_message_list(point))
if carry_unmatched
else ()
)
applied_message_points: Final[Sequence[CacheControlMessageInjectionPoint]] = (
tuple(point for point in message_points if point.get("index") is not None)
tuple(point for point in message_points if not _placed_on_the_final_message_list(point))
if carry_unmatched
else tuple(message_points)
)
@ -661,15 +681,7 @@ class AnthropicCacheControlHook(CustomPromptManagement):
point: CacheControlMessageInjectionPoint, messages: list[AllMessageValues]
) -> list[int]:
"""Resolve which message indices an injection point targets."""
_targetted_index: Final[int | str | None] = point.get("index", None)
targetted_index: int | None = None
if isinstance(_targetted_index, str):
try:
targetted_index = int(_targetted_index)
except ValueError:
pass
else:
targetted_index = _targetted_index
targetted_index: int | None = _message_point_ordinal(point)
# Case 1: Target by specific index
if targetted_index is not None:

View file

@ -659,8 +659,9 @@ def _prompt_management_sees_a_provisional_message_list(
"""Tell the cache-control hook that this layer's messages are not the ones sent upstream.
A Responses request keeps its system prompt in ``instructions``, which only becomes a
system message when the chat-completion bridge builds one, so a role-targeted point
is placed by the bridge's pass rather than this one.
system message when the chat-completion bridge builds one, and its ``function_call`` and
``function_call_output`` items, which only become messages there too. So a role-targeted
point and one counting back from the end are placed by the bridge's pass rather than this one.
Only raised for a request that will be bridged. A provider serving Responses natively
gets no second pass, so this layer is the last one that can place anything and handing

View file

@ -527,6 +527,35 @@ def test_responses_bridge_points_skip_injection_when_function_call_marks_fill_th
assert anthropic_labels(_only_request(wire)) == _CLIENT_MARKED
def _responses_body_ending_in_tool_results(model: str, marker: str) -> dict[str, JsonValue]:
items: Final[list[JsonValue]] = [
{"role": "user", "content": [{"type": "input_text", "text": ASK}]},
*(
{
"type": "function_call",
"call_id": call_id(city),
"name": "lookup_weather",
"arguments": json.dumps({"city": city}),
}
for city in CITIES
),
*(
{"type": "function_call_output", "call_id": call_id(city), "output": f"sunny. marker-{marker}"}
for city in CITIES
),
]
return {**responses_body(model, marker), "input": items}
def test_responses_bridge_last_message_point_marks_the_trailing_tool_result(gateway: Gateway) -> None:
marker: Final = new_marker()
with wire_server(anthropic_peer) as wire, gateway.scenario() as scenario:
model: Final = _anthropic_deployment(scenario, wire, cache_control_injection_points=POINTS)
response: Final = gateway.request("POST", "/v1/responses", _responses_body_ending_in_tool_results(model, marker))
assert response.status_code == 200, response.text
assert anthropic_labels(_only_request(wire)) == [SYSTEM_LABEL, f"user:tool_result:{call_id(CITIES[-1])}"]
@pytest.mark.timeout(_OWNED_PROXY_TIMEOUT)
def test_response_cache_serves_the_capped_request_once(uncapped_gateway: Gateway) -> None:
marker: Final = new_marker()

View file

@ -4522,14 +4522,15 @@ class TestResponsesLayerReservesMarksOutsideTheProvisionalMessages:
*earlier, last = self._items(marked_calls)
items = [*earlier, *other_items, last]
provisional_messages = ResponsesAPIRequestUtils.responses_input_to_chat_messages(items)
kwargs: dict = {
"cache_control_injection_points": [{"location": "message", "index": -1}],
"cache_control_injection_points": [{"location": "message", "index": len(provisional_messages) - 1}],
CARRY_UNMATCHED_MESSAGE_POINTS: True,
}
_stamp_injection_points_for_the_hook(kwargs, self.MODEL, "anthropic", items, self._tools(marked_tools))
_, messages, _ = AnthropicCacheControlHook().get_chat_completion_prompt(
model=self.MODEL,
messages=ResponsesAPIRequestUtils.responses_input_to_chat_messages(items),
messages=provisional_messages,
non_default_params=kwargs,
prompt_id=None,
prompt_variables=None,

View file

@ -966,6 +966,44 @@ async def test_out_of_bounds_positional_points_are_not_revived_by_a_longer_list(
assert "cache_control" not in json.dumps(body["messages"])
@pytest.mark.asyncio
@pytest.mark.parametrize("index", [-1, "-1"], ids=["int", "str"])
async def test_end_relative_points_mark_the_tool_results_that_follow_the_last_message(index):
mock_post = AsyncMock(return_value=MockResponse(_ANTHROPIC_MESSAGES_PAYLOAD, 200))
body = await _bridged_body(
mock_post,
points=[{"location": "message", "index": index}],
input=[
{"role": "user", "content": [{"type": "input_text", "text": "Read a.txt and b.txt."}]},
{"type": "function_call", "call_id": "call_a", "name": "read_file", "arguments": '{"path": "a.txt"}'},
{"type": "function_call", "call_id": "call_b", "name": "read_file", "arguments": '{"path": "b.txt"}'},
{"type": "function_call_output", "call_id": "call_a", "output": "contents of a"},
{"type": "function_call_output", "call_id": "call_b", "output": "contents of b"},
],
)
marked_blocks = [
block.get("tool_use_id") or block.get("id") or block.get("text")
for message in body["messages"]
for block in message["content"]
if "cache_control" in json.dumps(block)
]
assert marked_blocks == ["call_b"]
@pytest.mark.asyncio
async def test_a_non_numeric_index_marks_nothing_and_the_request_still_goes_out():
mock_post = AsyncMock(return_value=MockResponse(_ANTHROPIC_MESSAGES_PAYLOAD, 200))
body = await _bridged_body(
mock_post,
points=[{"location": "message", "index": "last"}],
input=[{"role": "user", "content": [{"type": "input_text", "text": "only item"}]}],
)
assert "cache_control" not in json.dumps(body["system"])
assert "cache_control" not in json.dumps(body["messages"])
def _four_user_turns() -> list:
return [
item