fix(groq): strip assistant provider_specific_fields in message transform

This commit is contained in:
Utsab Dahal 2026-04-12 13:19:11 +05:45
parent 5544803b35
commit fe656d8d4f
2 changed files with 76 additions and 27 deletions

View file

@ -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

View file

@ -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"
)