From fe656d8d4f2a095e5d5c6a80d06f1da100f9452e Mon Sep 17 00:00:00 2001 From: Utsab Dahal Date: Sun, 12 Apr 2026 13:19:11 +0545 Subject: [PATCH] fix(groq): strip assistant provider_specific_fields in message transform --- litellm/llms/groq/chat/transformation.py | 19 +++--- tests/llm_translation/test_groq.py | 84 ++++++++++++++++++------ 2 files changed, 76 insertions(+), 27 deletions(-) diff --git a/litellm/llms/groq/chat/transformation.py b/litellm/llms/groq/chat/transformation.py index 34ea7b03dd9..5603aaf9611 100644 --- a/litellm/llms/groq/chat/transformation.py +++ b/litellm/llms/groq/chat/transformation.py @@ -1,6 +1,7 @@ """ Translate from OpenAI's `/v1/chat/completions` to Groq's `/v1/chat/completions` """ + from typing import ( Any, Coroutine, @@ -115,8 +116,7 @@ class GroqChatConfig(OpenAILikeChatConfig): @overload def _transform_messages( self, messages: List[AllMessageValues], model: str, is_async: Literal[True] - ) -> Coroutine[Any, Any, List[AllMessageValues]]: - ... + ) -> Coroutine[Any, Any, List[AllMessageValues]]: ... @overload def _transform_messages( @@ -124,8 +124,7 @@ class GroqChatConfig(OpenAILikeChatConfig): messages: List[AllMessageValues], model: str, is_async: Literal[False] = False, - ) -> List[AllMessageValues]: - ... + ) -> List[AllMessageValues]: ... def _transform_messages( self, messages: List[AllMessageValues], model: str, is_async: bool = False @@ -133,6 +132,8 @@ class GroqChatConfig(OpenAILikeChatConfig): for idx, message in enumerate(messages): """ 1. Don't pass 'null' function_call assistant message to groq - https://github.com/BerriAI/litellm/issues/5839 + 2. Strip LiteLLM-internal provider_specific_fields from assistant history + before sending to Groq, which rejects unknown assistant properties. """ if isinstance(message, BaseModel): _message = message.model_dump() @@ -142,6 +143,8 @@ class GroqChatConfig(OpenAILikeChatConfig): if assistant_message: new_message = ChatCompletionAssistantMessage(role="assistant") for k, v in _message.items(): + if k == "provider_specific_fields": + continue if v is not None: new_message[k] = v # type: ignore messages[idx] = new_message @@ -293,10 +296,10 @@ class GroqChatConfig(OpenAILikeChatConfig): json_mode=json_mode, ) - mapped_service_tier: Literal[ - "auto", "default", "flex" - ] = self._map_groq_service_tier( - original_service_tier=getattr(model_response, "service_tier") + mapped_service_tier: Literal["auto", "default", "flex"] = ( + self._map_groq_service_tier( + original_service_tier=getattr(model_response, "service_tier") + ) ) setattr(model_response, "service_tier", mapped_service_tier) return model_response diff --git a/tests/llm_translation/test_groq.py b/tests/llm_translation/test_groq.py index b82cffd4189..f6ee9c65f67 100644 --- a/tests/llm_translation/test_groq.py +++ b/tests/llm_translation/test_groq.py @@ -1,5 +1,6 @@ import os import sys +from typing import List, cast import pytest @@ -15,6 +16,8 @@ from litellm.llms.groq.chat.transformation import ( GroqChatConfig, GroqChatCompletionStreamingHandler, ) +from litellm.types.llms.openai import AllMessageValues + class TestGroq(BaseLLMChatTest): def get_base_completion_call_args(self) -> dict: @@ -29,12 +32,41 @@ class TestGroq(BaseLLMChatTest): def test_tool_call_with_empty_enum_property(self): pass - @pytest.mark.parametrize("model", ["groq/qwen/qwen3-32b", "groq/openai/gpt-oss-20b", "groq/openai/gpt-oss-120b"]) + @pytest.mark.parametrize( + "model", + ["groq/qwen/qwen3-32b", "groq/openai/gpt-oss-20b", "groq/openai/gpt-oss-120b"], + ) def test_reasoning_effort_in_supported_params(self, model): """Test that reasoning_effort is in the list of supported parameters for Groq""" supported_params = GroqChatConfig().get_supported_openai_params(model=model) assert "reasoning_effort" in supported_params + def test_transform_messages_strips_assistant_provider_specific_fields(self): + """Groq rejects unknown assistant fields like provider_specific_fields.""" + config = GroqChatConfig() + messages = cast( + List[AllMessageValues], + [ + { + "role": "assistant", + "content": "Tool metadata", + "provider_specific_fields": { + "mcp_list_tools": [{"name": "weather"}], + "mcp_tool_calls": [{"id": "call_123"}], + }, + } + ], + ) + + transformed = cast( + List[AllMessageValues], + config._transform_messages(messages=messages, model="qwen/qwen3-32b"), + ) + + assert transformed[0]["role"] == "assistant" + assert transformed[0].get("content") == "Tool metadata" + assert "provider_specific_fields" not in transformed[0] + class TestGroqStructuredOutputs: """ @@ -66,19 +98,19 @@ class TestGroqStructuredOutputs: "schema": { "type": "object", "properties": {"name": {"type": "string"}}, - "required": ["name"] - } - } + "required": ["name"], + }, + }, }, "tools": [ { "type": "function", "function": { "name": "get_weather", - "parameters": {"type": "object", "properties": {}} - } + "parameters": {"type": "object", "properties": {}}, + }, } - ] + ], } with pytest.raises(litellm.BadRequestError) as exc_info: @@ -92,7 +124,9 @@ class TestGroqStructuredOutputs: assert "does not support native structured outputs" in str(exc_info.value) assert "incompatible with user-provided tools" in str(exc_info.value) - def test_structured_output_without_tools_uses_workaround_for_non_native_models(self): + def test_structured_output_without_tools_uses_workaround_for_non_native_models( + self, + ): """ Test that structured outputs without tools works using the json_tool_call workaround for models that don't support native json_schema. @@ -109,9 +143,9 @@ class TestGroqStructuredOutputs: "schema": { "type": "object", "properties": {"name": {"type": "string"}}, - "required": ["name"] - } - } + "required": ["name"], + }, + }, } } @@ -147,9 +181,9 @@ class TestGroqStructuredOutputs: "schema": { "type": "object", "properties": {"name": {"type": "string"}}, - "required": ["name"] - } - } + "required": ["name"], + }, + }, } } @@ -172,7 +206,7 @@ class TestGroqStructuredOutputs: class TestGroqReasoning: """ Tests for Groq reasoning field mapping. - + Groq returns 'reasoning' field in delta, but LiteLLM expects 'reasoning_content'. """ @@ -207,7 +241,10 @@ class TestGroqReasoning: parsed_chunk = handler.chunk_parser(groq_chunk) # Verify that reasoning was mapped to reasoning_content - assert parsed_chunk.choices[0].delta.reasoning_content == "This is reasoning content" + assert ( + parsed_chunk.choices[0].delta.reasoning_content + == "This is reasoning content" + ) # Verify that the original 'reasoning' field was removed assert not hasattr(parsed_chunk.choices[0].delta, "reasoning") @@ -268,7 +305,10 @@ class TestGroqReasoning: { "index": 0, "id": "call_123", - "function": {"name": "test_function", "arguments": "{}"}, + "function": { + "name": "test_function", + "arguments": "{}", + }, "type": "function", } ], @@ -283,8 +323,14 @@ class TestGroqReasoning: parsed_chunk = handler.chunk_parser(groq_chunk) # Verify that reasoning was mapped to reasoning_content - assert parsed_chunk.choices[0].delta.reasoning_content == "Reasoning before tool call" + assert ( + parsed_chunk.choices[0].delta.reasoning_content + == "Reasoning before tool call" + ) # Verify tool_calls are still present assert parsed_chunk.choices[0].delta.tool_calls is not None assert len(parsed_chunk.choices[0].delta.tool_calls) == 1 - assert parsed_chunk.choices[0].delta.tool_calls[0]["function"]["name"] == "test_function" + assert ( + parsed_chunk.choices[0].delta.tool_calls[0]["function"]["name"] + == "test_function" + )