From 75f117ae50481e711f41212e89bbe24e880a4723 Mon Sep 17 00:00:00 2001 From: RockChinQ Date: Sun, 6 Sep 2026 00:33:20 +0800 Subject: [PATCH] fix(provider): preserve explicit streaming finish reasons --- .../modelmgr/requesters/litellmchat.py | 3 ++ tests/unit_tests/provider/test_litellmchat.py | 28 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py b/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py index 1d3adcee5..8800caea2 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py +++ b/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py @@ -1278,6 +1278,9 @@ class LiteLLMRequester(requester.ProviderAPIRequester): delta_content = delta.get('content') or '' reasoning_content = delta.get('reasoning_content') or '' provider_fields = dict(delta.get('provider_specific_fields') or {}) + if finish_reason: + # Preserve an explicit provider stop even when the final delta is empty. + provider_fields['finish_reason'] = finish_reason raw_thinking_blocks = delta.get('thinking_blocks') if raw_thinking_blocks: thinking_blocks_state = self._merge_thinking_blocks(thinking_blocks_state, raw_thinking_blocks) diff --git a/tests/unit_tests/provider/test_litellmchat.py b/tests/unit_tests/provider/test_litellmchat.py index d684ca9e5..490f7aa12 100644 --- a/tests/unit_tests/provider/test_litellmchat.py +++ b/tests/unit_tests/provider/test_litellmchat.py @@ -199,6 +199,34 @@ class TestInvokeLLMStreamUsage: chunk.choices = [] return chunk + @pytest.mark.asyncio + @pytest.mark.parametrize('finish_reason', ['stop', 'length', 'content_filter', 'tool_calls']) + async def test_empty_final_delta_preserves_provider_finish_reason(self, finish_reason): + import langbot_plugin.api.entities.builtin.provider.message as provider_message + + mock_ap = Mock() + mock_ap.tool_mgr.generate_tools_for_openai = AsyncMock(return_value=None) + requester = litellmchat.LiteLLMRequester(ap=mock_ap, config={}) + model = MockRuntimeModel('gpt-4o', 'test-api-key') + + async def stream(): + yield self._make_chunk(finish_reason=finish_reason) + + with patch.object(litellmchat, 'acompletion', new=AsyncMock(side_effect=lambda **kw: stream())): + chunks = [ + chunk + async for chunk in requester.invoke_llm_stream( + query=None, + model=model, + messages=[provider_message.Message(role='user', content='Hi')], + ) + ] + + assert len(chunks) == 1 + assert chunks[0].is_final + assert not chunks[0].content + assert chunks[0].provider_specific_fields['finish_reason'] == finish_reason + @pytest.mark.asyncio async def test_stream_usage_with_nonempty_choices(self): """Usage chunk that still has a choice must populate _stream_usage."""