From 807de8d24ad2f8ad43bc1b6de472a6248559d160 Mon Sep 17 00:00:00 2001 From: BiFangKNT <1320414964@qq.com> Date: Sat, 17 Oct 2026 19:48:25 +0800 Subject: [PATCH] fix(provider): sanitize replayed Groq message metadata Remove response-only finish_reason metadata when converting stored messages into subsequent model requests. Strip provider_specific_fields from Groq messages and tool calls to avoid strict request validation failures after a tool response. Preserve metadata required by other providers, including Gemini thought signatures, without mutating the original message entities. Cover streaming and non-streaming request construction with regression tests. --- .../modelmgr/requesters/litellmchat.py | 15 +++++ .../provider/test_provider_specific_fields.py | 58 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py b/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py index 300fb7551..04fd5d0df 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py +++ b/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py @@ -836,6 +836,13 @@ class LiteLLMRequester(requester.ProviderAPIRequester): req_messages = [] for m in messages: msg_dict = m.dict(exclude_none=True) + provider_fields = msg_dict.get('provider_specific_fields') + if isinstance(provider_fields, dict): + # Stream completion metadata belongs to the response, never to + # replayed chat messages. Keep provider signatures for LiteLLM. + provider_fields.pop('finish_reason', None) + if not provider_fields: + msg_dict.pop('provider_specific_fields', None) content = msg_dict.get('content') if msg_dict.get('role') == 'assistant' and reasoning_family: @@ -871,6 +878,14 @@ class LiteLLMRequester(requester.ProviderAPIRequester): else: msg_dict.pop('provider_specific_fields', None) + if reasoning_family == 'groq': + # Groq validates message and tool-call fields strictly and does + # not accept LiteLLM's provider metadata envelope. + msg_dict.pop('provider_specific_fields', None) + for tool_call in msg_dict.get('tool_calls') or []: + tool_call.pop('provider_specific_fields', None) + tool_call.get('function', {}).pop('provider_specific_fields', None) + if isinstance(content, list): converted_parts = [] for part in content: diff --git a/tests/unit_tests/provider/test_provider_specific_fields.py b/tests/unit_tests/provider/test_provider_specific_fields.py index fe239f3ea..2607ad7d5 100644 --- a/tests/unit_tests/provider/test_provider_specific_fields.py +++ b/tests/unit_tests/provider/test_provider_specific_fields.py @@ -148,6 +148,64 @@ def test_convert_messages_preserves_message_provider_specific_fields(): assert out[0]['provider_specific_fields']['thought_signatures'] == ['sig1', 'sig2'] +@pytest.mark.parametrize('family', ['', 'openai', 'gemini', 'groq']) +def test_response_finish_reason_is_not_replayed(family): + req = _make_requester() + msg = provider_message.Message( + role='assistant', content='Hello', provider_specific_fields={'finish_reason': 'stop'} + ) + + assert req._convert_messages([msg], reasoning_family=family) == [{'role': 'assistant', 'content': 'Hello'}] + assert msg.provider_specific_fields == {'finish_reason': 'stop'} + + +def test_response_metadata_removal_preserves_gemini_signatures(): + req = _make_requester() + msg = provider_message.Message( + role='assistant', + content='Hello', + provider_specific_fields={'finish_reason': 'stop', 'thought_signatures': ['signature']}, + ) + + out = req._convert_messages([msg], reasoning_family='gemini') + + assert out[0]['provider_specific_fields'] == {'thought_signatures': ['signature']} + + +@pytest.mark.asyncio +@pytest.mark.parametrize('stream', [False, True]) +async def test_groq_completion_drops_provider_metadata_from_replayed_messages(stream): + req = _make_configured_requester() + req.requester_cfg['custom_llm_provider'] = 'groq' + model = _make_runtime_model() + model.model_entity.name = 'openai/gpt-oss-120b' + msg = provider_message.Message( + role='assistant', + content='Hello', + provider_specific_fields={'finish_reason': 'tool_calls', 'other_response_metadata': True}, + tool_calls=[ + provider_message.ToolCall( + id='call-1', + type='function', + function=provider_message.FunctionCall(name='search', arguments='{}'), + provider_specific_fields={'thought_signature': 'signature'}, + ) + ], + ) + + args = await req._build_completion_args(model, [msg], None, {}, stream=stream) + + assert args['model'] == 'groq/openai/gpt-oss-120b' + assert args['messages'] == [ + { + 'role': 'assistant', + 'content': 'Hello', + 'tool_calls': [{'id': 'call-1', 'type': 'function', 'function': {'name': 'search', 'arguments': '{}'}}], + } + ] + assert msg.provider_specific_fields['finish_reason'] == 'tool_calls' + + def test_normalize_stream_tool_calls_preserves_provider_specific_fields(): """Streaming tool calls should retain provider_specific_fields.""" req = _make_requester()