fix(caching): treat whitespace-only replies as no output

This commit is contained in:
mateo-berri 2026-11-06 14:31:51 -08:00
parent ddf3a86aad
commit d21e29981b
2 changed files with 54 additions and 6 deletions

View file

@ -154,9 +154,13 @@ def _content_block_payload(event: Mapping[str, object]) -> object:
return None
def _is_blank(value: object) -> bool:
return value in ("", [], {}, None) or (isinstance(value, str) and not value.strip())
def _content_block_carries_output(block: object) -> bool:
fields: Final = as_str_mapping(block)
return fields is not None and any(key != "type" and value not in ("", [], None) for key, value in fields.items())
return fields is not None and any(key != "type" and not _is_blank(value) for key, value in fields.items())
def _sse_line_carries_output(line: str) -> bool:
@ -178,12 +182,9 @@ def _message_field_carries_output(key: str, value: object) -> bool:
return False
case "provider_specific_fields":
fields: Final = as_str_mapping(value) or {}
return any(
name not in _REPLAY_METADATA_FIELDS and field not in ("", [], {}, None)
for name, field in fields.items()
)
return any(name not in _REPLAY_METADATA_FIELDS and not _is_blank(field) for name, field in fields.items())
case _:
return value not in ("", [], {}, None)
return not _is_blank(value)
def _choice_output_fields(choice: object) -> Mapping[str, object]:

View file

@ -2765,6 +2765,10 @@ def _anthropic_message_with_only_an_empty_text_block() -> dict[str, object]:
}
def _anthropic_message_with_only_a_whitespace_text_block() -> dict[str, object]:
return {**_anthropic_message_with_only_an_empty_text_block(), "content": [{"type": "text", "text": "\n\n"}]}
def _empty_reply_with_provider_metadata(provider_specific_fields: dict[str, object]) -> ModelResponse:
return litellm.ModelResponse(
choices=[litellm.Choices(message=litellm.Message(content="", provider_specific_fields=provider_specific_fields))]
@ -2846,6 +2850,18 @@ def _empty_response_cases() -> list[EmptyResponseCase]:
CallTypes.aanthropic_messages.value,
{"messages": _unique_messages(), "max_tokens": 16},
),
(
_anthropic_message_with_only_a_whitespace_text_block(),
aanthropic_messages,
CallTypes.aanthropic_messages.value,
{"messages": _unique_messages(), "max_tokens": 16},
),
(
litellm.ModelResponse(choices=[litellm.Choices(message=litellm.Message(content="\n\n"))]),
litellm.acompletion,
CallTypes.acompletion.value,
{"messages": _unique_messages()},
),
(
_empty_reply_with_provider_metadata({"thought_signatures": ["c2lnbmF0dXJl"]}),
litellm.acompletion,
@ -2897,6 +2913,15 @@ def _empty_response_cases() -> list[EmptyResponseCase]:
CallTypes.aanthropic_messages.value,
{"messages": _unique_messages(), "max_tokens": 16, "stream": True},
),
(
_anthropic_stream_with_only_empty_blocks(
{"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}},
{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "\n\n"}},
),
aanthropic_messages,
CallTypes.aanthropic_messages.value,
{"messages": _unique_messages(), "max_tokens": 16, "stream": True},
),
]
@ -2951,6 +2976,28 @@ async def test_async_get_cache_treats_stored_response_without_output_as_miss(
assert lookup.cached_result is None
@pytest.mark.asyncio
async def test_async_set_cache_keeps_reply_whose_text_is_padded_with_whitespace():
setup_cache()
handler: Final = LLMCachingHandler(original_function=litellm.acompletion, request_kwargs={}, start_time=_FIXED_START)
kwargs: Final = {"messages": _unique_messages()}
reply: Final = litellm.ModelResponse(choices=[litellm.Choices(message=litellm.Message(content="\n\nParis\n"))])
await handler.async_set_cache(result=reply, original_function=litellm.acompletion, kwargs=kwargs)
await asyncio.gather(*_PENDING_CACHE_WRITES)
lookup: Final = await handler.async_get_cache(
model="gpt-3.5-turbo",
original_function=litellm.acompletion,
logging_obj=_completion_logging_obj(CallTypes.acompletion.value),
start_time=_FIXED_START,
call_type=CallTypes.acompletion.value,
kwargs=kwargs,
)
assert isinstance(lookup.cached_result, ModelResponse)
assert lookup.cached_result.choices[0].message.content == "\n\nParis\n"
@pytest.mark.asyncio
async def test_async_get_cache_heals_stored_completion_without_choices():
setup_cache()