Skip to content

Commit 0c2616c

Browse files
fix(models): harden LiteLLM request handling (#197)
* fix(models): harden LiteLLM request handling Co-authored-by: Cursor <cursoragent@cursor.com> * fix(models): preserve legacy function call messages Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 177d463 commit 0c2616c

4 files changed

Lines changed: 110 additions & 35 deletions

File tree

openjudge/models/litellm_chat_model.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -99,11 +99,7 @@ async def achat(
9999
"""
100100
import litellm
101101

102-
if not isinstance(messages, list):
103-
raise ValueError(
104-
f"LiteLLM `messages` field expected type `list`, got `{type(messages)}` instead.",
105-
)
106-
messages = [msg.to_dict() if isinstance(msg, ChatMessage) else msg for msg in messages]
102+
messages = self._normalize_and_validate_messages(messages, "LiteLLM")
107103

108104
call_kwargs: Dict[str, Any] = {
109105
"model": self.model,
@@ -155,6 +151,6 @@ async def achat(
155151

156152
response = await litellm.acompletion(**call_kwargs)
157153

158-
if self.stream:
154+
if call_kwargs["stream"]:
159155
return self._handle_streaming_response(response, structured_model, callback)
160156
return self._handle_non_streaming_response(response, structured_model, callback)

openjudge/models/openai_chat_model.py

Lines changed: 33 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,38 @@ def __init__(
115115

116116
self.client = AsyncOpenAI(**client_args)
117117

118+
@staticmethod
119+
def _normalize_and_validate_messages(
120+
messages: list[dict | ChatMessage],
121+
provider_name: str,
122+
) -> list[dict]:
123+
"""Convert ``ChatMessage`` objects and validate OpenAI message shape."""
124+
if not isinstance(messages, list):
125+
raise ValueError(
126+
f"{provider_name} `messages` field expected type `list`, " f"got `{type(messages)}` instead.",
127+
)
128+
129+
normalized_messages = [msg.to_dict() if isinstance(msg, ChatMessage) else msg for msg in messages]
130+
131+
def _is_valid_message(msg: dict) -> bool:
132+
if not isinstance(msg, dict) or "role" not in msg:
133+
return False
134+
role = msg["role"]
135+
if role == "assistant" and ("tool_calls" in msg or "function_call" in msg):
136+
return True
137+
if role == "tool":
138+
return "tool_call_id" in msg and "content" in msg
139+
return "content" in msg
140+
141+
if not all(_is_valid_message(msg) for msg in normalized_messages):
142+
raise ValueError(
143+
"Invalid message format. Each message must have 'role' and appropriate fields. "
144+
"User/system messages need 'content'. Tool messages need 'tool_call_id' and 'content'. "
145+
"Assistant messages with 'tool_calls' or 'function_call' don't require 'content'.",
146+
)
147+
148+
return normalized_messages
149+
118150
async def achat(
119151
self,
120152
messages: list[dict | ChatMessage],
@@ -164,34 +196,7 @@ async def achat(
164196
The response from the OpenAI chat completions API.
165197
"""
166198

167-
# checking messages
168-
if not isinstance(messages, list):
169-
raise ValueError(
170-
"OpenAI `messages` field expected type `list`, " f"got `{type(messages)}` instead.",
171-
)
172-
messages = [msg.to_dict() if isinstance(msg, ChatMessage) else msg for msg in messages]
173-
174-
# Validate messages - note that for assistant messages with tool_calls,
175-
# content can be None or missing (this is valid OpenAI format)
176-
def _is_valid_message(msg: dict) -> bool:
177-
if not isinstance(msg, dict) or "role" not in msg:
178-
return False
179-
role = msg["role"]
180-
# Assistant messages with tool_calls don't require content
181-
if role == "assistant" and "tool_calls" in msg:
182-
return True
183-
# Tool messages require tool_call_id and content
184-
if role == "tool":
185-
return "tool_call_id" in msg and "content" in msg
186-
# All other messages require content
187-
return "content" in msg
188-
189-
if not all(_is_valid_message(msg) for msg in messages):
190-
raise ValueError(
191-
"Invalid message format. Each message must have 'role' and appropriate fields. "
192-
"User/system messages need 'content'. Tool messages need 'tool_call_id' and 'content'. "
193-
"Assistant messages with 'tool_calls' don't require 'content'.",
194-
)
199+
messages = self._normalize_and_validate_messages(messages, "OpenAI")
195200

196201
# Qwen-omni requires different base64 audio format from openai
197202
if "omni" in self.model.lower():

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ verl = [
8282
"verl"
8383
]
8484
litellm = [
85-
"litellm>=1.89.0,<2.0.0"
85+
"litellm>=1.96.2,<2.0.0"
8686
]
8787

8888
[project.urls]

tests/models/test_litellm_chat_model.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,3 +161,77 @@ async def test_non_list_messages_raises(self):
161161
model = LiteLLMChatModel(model="gpt-4o")
162162
with pytest.raises(ValueError):
163163
await model.achat(messages="not a list")
164+
165+
async def test_assistant_function_call_without_content_is_accepted(self):
166+
_, calls = _install_litellm_stub()
167+
model = LiteLLMChatModel(model="gpt-4o")
168+
messages = [
169+
{
170+
"role": "assistant",
171+
"function_call": {
172+
"name": "lookup",
173+
"arguments": "{}",
174+
},
175+
},
176+
]
177+
178+
await model.achat(messages=messages)
179+
180+
assert calls[-1]["messages"] == messages
181+
182+
@pytest.mark.parametrize(
183+
"messages",
184+
[
185+
[{"content": "missing role"}],
186+
[{"role": "user"}],
187+
[{"role": "tool", "content": "missing tool call id"}],
188+
[42],
189+
],
190+
ids=["missing_role", "missing_content", "missing_tool_call_id", "non_dict"],
191+
)
192+
async def test_invalid_message_format_raises(self, messages):
193+
_, calls = _install_litellm_stub()
194+
model = LiteLLMChatModel(model="gpt-4o")
195+
196+
with pytest.raises(ValueError, match="Invalid message format"):
197+
await model.achat(messages=messages)
198+
assert calls == []
199+
200+
@pytest.mark.parametrize(
201+
"instance_stream, call_stream, expected_handler",
202+
[
203+
(False, True, "streaming"),
204+
(True, False, "non_streaming"),
205+
],
206+
)
207+
async def test_call_stream_override_selects_matching_response_handler(
208+
self,
209+
monkeypatch,
210+
instance_stream,
211+
call_stream,
212+
expected_handler,
213+
):
214+
_, calls = _install_litellm_stub()
215+
model = LiteLLMChatModel(model="gpt-4o", stream=instance_stream)
216+
streaming_result = object()
217+
non_streaming_result = object()
218+
219+
monkeypatch.setattr(
220+
model,
221+
"_handle_streaming_response",
222+
lambda *_args: streaming_result,
223+
)
224+
monkeypatch.setattr(
225+
model,
226+
"_handle_non_streaming_response",
227+
lambda *_args: non_streaming_result,
228+
)
229+
230+
result = await model.achat(
231+
messages=[{"role": "user", "content": "hi"}],
232+
stream=call_stream,
233+
)
234+
235+
assert calls[-1]["stream"] is call_stream
236+
expected_result = streaming_result if expected_handler == "streaming" else non_streaming_result
237+
assert result is expected_result

0 commit comments

Comments
 (0)