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.
This commit is contained in:
BiFangKNT
2026-10-17 19:48:25 +08:00
parent 2ca9c2c7fb
commit 807de8d24a
2 changed files with 73 additions and 0 deletions
@@ -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:
@@ -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()