diff --git a/litellm/caching/caching_handler.py b/litellm/caching/caching_handler.py index ab5d2332c21..4e93741bf97 100644 --- a/litellm/caching/caching_handler.py +++ b/litellm/caching/caching_handler.py @@ -1067,6 +1067,7 @@ class LLMCachingHandler: cached_result = convert_to_model_response_object( response_object=cached_result, model_response_object=ModelResponse(), + _preserve_finish_reason=True, ) if ( call_type == CallTypes.atext_completion.value or call_type == CallTypes.text_completion.value @@ -1140,6 +1141,7 @@ class LLMCachingHandler: cached_result = convert_to_model_response_object( response_object=cached_result, model_response_object=ModelResponse(), + _preserve_finish_reason=True, ) else: from litellm.responses.streaming_iterator import ( diff --git a/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py b/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py index 0fed2cb2fce..b1eb11de75c 100644 --- a/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py +++ b/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py @@ -747,6 +747,7 @@ def convert_to_model_response_object( hidden_params: dict | None = None, _response_headers: dict | None = None, convert_tool_call_to_json_mode: bool | None = None, # used for supporting 'json_schema' on older models + _preserve_finish_reason: bool = False, # cached responses already have their final finish_reason ): additional_headers = get_response_headers(_response_headers) @@ -888,7 +889,12 @@ def convert_to_model_response_object( if finish_reason is None: # gpt-4 vision can return 'finish_reason' or 'finish_details' finish_reason = choice.get("finish_details") or "stop" - if finish_reason == "stop" and message.tool_calls and len(message.tool_calls) > 0: + if ( + not _preserve_finish_reason + and finish_reason == "stop" + and message.tool_calls + and len(message.tool_calls) > 0 + ): finish_reason = "tool_calls" ## PROVIDER SPECIFIC FIELDS ## diff --git a/tests/unit/caching/test_caching_handler.py b/tests/unit/caching/test_caching_handler.py index 024f9de2fb3..3d6a0ddf54b 100644 --- a/tests/unit/caching/test_caching_handler.py +++ b/tests/unit/caching/test_caching_handler.py @@ -2052,6 +2052,90 @@ def test_convert_cached_responses_bridge_chat_completion_nonstream(): assert result.choices[0].message.content == "Hi!" +def test_convert_cached_completion_preserves_stored_finish_reason(): + """A cache hit must replay the stored finish_reason, not re-derive it from tool_calls.""" + caching_handler = LLMCachingHandler( + original_function=completion, request_kwargs={}, start_time=datetime.now() + ) + cached_result = { + "id": "chatcmpl-cache-finish-reason", + "object": "chat.completion", + "created": int(time.time()), + "model": "claude-sonnet-4-5", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "toolu_1", + "type": "function", + "function": {"name": "get_weather", "arguments": "{}"}, + } + ], + }, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } + + result = caching_handler._convert_cached_result_to_model_response( + cached_result=cached_result, + call_type=CallTypes.completion.value, + kwargs={ + "model": "claude-sonnet-4-5", + "stream": False, + "messages": [{"role": "user", "content": "weather?"}], + }, + logging_obj=_build_logging_obj(CallTypes.completion.value, stream=False), + model="claude-sonnet-4-5", + args=(), + ) + + assert isinstance(result, ModelResponse) + assert result.choices[0].message.tool_calls is not None + assert result.choices[0].finish_reason == "stop" + + +def test_convert_raw_completion_still_derives_finish_reason_from_tool_calls(): + """Provider payloads that say 'stop' but carry tool calls still map to 'tool_calls'.""" + from litellm.utils import convert_to_model_response_object + + result = convert_to_model_response_object( + response_object={ + "id": "chatcmpl-raw-finish-reason", + "object": "chat.completion", + "created": int(time.time()), + "model": "claude-sonnet-4-5", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "toolu_1", + "type": "function", + "function": {"name": "get_weather", "arguments": "{}"}, + } + ], + }, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + }, + model_response_object=ModelResponse(), + ) + + assert result is not None + assert result.choices[0].finish_reason == "tool_calls" + + def test_convert_cached_responses_legacy_nonstream_path(): """Genuine ResponsesAPIResponse dict (no chatcmpl/choices) falls through legacy path.""" from litellm import responses