Skip to content

Commit 739d576

Browse files
committed
[https://nvbugs/6384951] Fix incorrect error reporting
* Why? tau2-bench required a workaround that could misreport errors. * What? This commit fixes that with typed checks and adds tests for it. Signed-off-by: William Zhang <133824995+2ez4bz@users.noreply.github.com>
1 parent 9ff00c8 commit 739d576

2 files changed

Lines changed: 238 additions & 18 deletions

File tree

tensorrt_llm/serve/chat_utils.py

Lines changed: 113 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
ChatCompletionContentPartParam as OpenAIChatCompletionContentPartParam
1212
from openai.types.chat import (ChatCompletionContentPartTextParam,
1313
ChatCompletionMessageParam)
14+
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
1415
from transformers import AutoConfig
1516
from typing_extensions import Required
1617

@@ -270,6 +271,109 @@ def parse_chat_message_content(
270271
return result
271272

272273

274+
# The below 2 models are for tau2-bench workarounds (see `_parse_fallback_tool_calls` for details).
275+
class _FallbackFunctionCall(BaseModel):
276+
"""Lenient, typed view of a tool call's `function` for the fallback path."""
277+
model_config = ConfigDict(extra="allow")
278+
279+
name: str | None = None
280+
arguments: str | dict[str, Any] | None = None
281+
282+
283+
class _FallbackToolCall(BaseModel):
284+
"""Lenient, typed view of a single tool call for the fallback path."""
285+
model_config = ConfigDict(extra="allow")
286+
287+
id: str | None = None
288+
type: str | None = None
289+
function: _FallbackFunctionCall
290+
291+
292+
_FALLBACK_TOOL_CALLS_ADAPTER = TypeAdapter(list[_FallbackToolCall])
293+
294+
295+
def _format_tool_call_error(errors: list[dict[str, Any]]) -> str:
296+
"""Render the first Pydantic error for fallback tool calls as a client-facing message."""
297+
err = errors[0]
298+
loc = err.get("loc", ())
299+
index = loc[0] if loc and isinstance(loc[0], int) else 0
300+
error_type = err.get("type", "")
301+
# Path within the tool call, after the leading list index.
302+
field = ".".join(str(p) for p in loc[1:])
303+
target = f"tool_calls[{index}].{field}" if field else f"tool_calls[{index}]"
304+
305+
if error_type == "missing":
306+
return f"{target} is required."
307+
# Pydantic's default message for these leaks the internal model name, so phrase the "value must
308+
# be an object" case explicitly.
309+
if error_type in ("model_type", "dict_type", "model_attributes_type"):
310+
return f"{target} must be an object."
311+
return f"{target} is invalid: {err.get('msg', 'validation error')}"
312+
313+
314+
def _validate_fallback_tool_calls(
315+
tool_calls: list[Any]) -> list[dict[str, Any]]:
316+
"""Re-validate raw, Pydantic-rejected tool calls from the openai_server through the model above.
317+
318+
This keeps the tau2-bench leniency that motivated the raw-JSON fallback while restoring type
319+
safety: malformed input raises `ValueError` (mapped to HTTP 400 by the server) instead of
320+
crashing the handler with an opaque `KeyError` / `TypeError`/ `JSONDecodeError` from unvalidated
321+
dicts.
322+
"""
323+
try:
324+
validated = _FALLBACK_TOOL_CALLS_ADAPTER.validate_python(tool_calls)
325+
except ValidationError as e:
326+
raise ValueError(_format_tool_call_error(e.errors())) from e
327+
# `exclude_unset` preserves the caller's original shape (e.g. omitted `id` / `type` are not
328+
# re-introduced), while `extra="allow"` keeps any additional fields the client sent.
329+
return [tc.model_dump(exclude_unset=True) for tc in validated]
330+
331+
332+
def _normalize_tool_call_arguments(index: int, item: Any) -> dict[str, Any]:
333+
"""Normalize `function.arguments` to the internal dict form."""
334+
item = dict(item)
335+
item["function"] = dict(item["function"])
336+
337+
arguments = item["function"].get("arguments")
338+
if arguments is None:
339+
item["function"]["arguments"] = {}
340+
elif isinstance(arguments, str):
341+
try:
342+
arguments = json.loads(arguments)
343+
except json.JSONDecodeError as e:
344+
raise ValueError(
345+
f"tool_calls[{index}].function.arguments must be valid JSON."
346+
) from e
347+
if not isinstance(arguments, dict):
348+
raise ValueError(
349+
f"tool_calls[{index}].function.arguments must be a JSON object."
350+
)
351+
item["function"]["arguments"] = arguments
352+
elif isinstance(arguments, dict):
353+
item["function"]["arguments"] = arguments
354+
else:
355+
raise ValueError(
356+
f"tool_calls[{index}].function.arguments must be an object.")
357+
return item
358+
359+
360+
def _parse_fallback_tool_calls(tool_calls: list[Any]) -> list[dict[str, Any]]:
361+
"""Parse raw tool-call lists accepted only by the tau2-bench fallback path.
362+
363+
`openai_server.py` first attempts strict OpenAI request validation. Some tau2-bench requests
364+
send tool calls in a shape that fails that strict model, so the server falls back to parsing the
365+
raw JSON payload and passes a plain list here. Because that list has skipped Pydantic
366+
validation, keep the compatibility workaround contained in this helper and re-apply the minimum
367+
checks we rely on: each tool call and `function` must be object-shaped, malformed inputs should
368+
become client-facing `ValueError`s, and `function.arguments` must normalize to a JSON object.
369+
"""
370+
tool_calls = _validate_fallback_tool_calls(tool_calls)
371+
return [
372+
_normalize_tool_call_arguments(index, item)
373+
for index, item in enumerate(tool_calls)
374+
]
375+
376+
273377
# Adapted from: https://github.com/vllm-project/vllm/blob/4574d48bab9c4e38b7c0a830eeefc8f0980e8c58/vllm/entrypoints/chat_utils.py#L1406
274378
def _parse_assistant_message_content(message: Dict[str, Any]) -> Dict[str, Any]:
275379
result = {}
@@ -282,25 +386,16 @@ def _parse_assistant_message_content(message: Dict[str, Any]) -> Dict[str, Any]:
282386

283387
tool_calls = message.get("tool_calls")
284388
if tool_calls is not None:
285-
# Materialize Pydantic v2 ValidatorIterator (single-use) to a list.
286-
if not isinstance(tool_calls, list):
389+
if isinstance(tool_calls, list):
390+
result["tool_calls"] = _parse_fallback_tool_calls(tool_calls)
391+
else:
392+
# The strict parse path delivers tool_calls as a single-use Pydantic `ValidatorIterator`
393+
# of already-validated OpenAI tool calls, so only materialize and normalize arguments.
287394
tool_calls = list(tool_calls)
288-
289-
result["tool_calls"] = []
290-
for item in tool_calls:
291-
# Bypass pydantic check to WAR `tau2-bench-telecom` ill-format tool_call.
292-
item = dict(item)
293-
if "function" in item:
294-
item["function"] = dict(item["function"])
295-
296-
if content := item["function"].get("arguments"):
297-
if isinstance(content, str):
298-
item["function"]["arguments"] = json.loads(content)
299-
else:
300-
item["function"]["arguments"] = content
301-
else:
302-
item["function"]["arguments"] = {}
303-
result["tool_calls"].append(item)
395+
result["tool_calls"] = [
396+
_normalize_tool_call_arguments(index, item)
397+
for index, item in enumerate(tool_calls)
398+
]
304399

305400
return result
306401

tests/unittest/llmapi/apps/test_chat_utils.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,131 @@ def test_assistant_message_with_empty_tool_arguments(self, mock_mm_data_tracker)
117117
}
118118
assert result == expected
119119

120+
def test_assistant_message_tool_call_missing_function(self, mock_mm_data_tracker) -> None:
121+
message = {
122+
"role": "assistant",
123+
"content": None,
124+
"tool_calls": [{"id": "call_123", "type": "function"}],
125+
}
126+
127+
with pytest.raises(ValueError, match=r"tool_calls\[0\]\.function is required"):
128+
parse_chat_message_content(message, mock_mm_data_tracker)
129+
130+
def test_assistant_message_tool_call_invalid_json_arguments(self, mock_mm_data_tracker) -> None:
131+
message = {
132+
"role": "assistant",
133+
"content": None,
134+
"tool_calls": [
135+
{
136+
"id": "call_123",
137+
"type": "function",
138+
"function": {
139+
"name": "get_weather",
140+
"arguments": "not json {",
141+
},
142+
}
143+
],
144+
}
145+
146+
with pytest.raises(
147+
ValueError,
148+
match=r"tool_calls\[0\]\.function\.arguments must be valid JSON",
149+
):
150+
parse_chat_message_content(message, mock_mm_data_tracker)
151+
152+
@pytest.mark.parametrize(
153+
("arguments", "match"),
154+
[
155+
("", r"tool_calls\[0\]\.function\.arguments must be valid JSON"),
156+
("[]", r"tool_calls\[0\]\.function\.arguments must be a JSON object"),
157+
("0", r"tool_calls\[0\]\.function\.arguments must be a JSON object"),
158+
],
159+
)
160+
def test_assistant_message_tool_call_rejects_non_object_json_arguments(
161+
self, mock_mm_data_tracker, arguments, match
162+
) -> None:
163+
message = {
164+
"role": "assistant",
165+
"content": None,
166+
"tool_calls": [
167+
{
168+
"id": "call_123",
169+
"type": "function",
170+
"function": {
171+
"name": "get_weather",
172+
"arguments": arguments,
173+
},
174+
}
175+
],
176+
}
177+
178+
with pytest.raises(ValueError, match=match):
179+
parse_chat_message_content(message, mock_mm_data_tracker)
180+
181+
@pytest.mark.parametrize(
182+
"function_value",
183+
[None, 123, "hi", ["a", "b"]],
184+
)
185+
def test_assistant_message_tool_call_non_object_function(
186+
self, mock_mm_data_tracker, function_value
187+
) -> None:
188+
# A present-but-non-object `function` must raise a clean ValueError (HTTP 400) rather than
189+
# an opaque TypeError/ValueError from dict() coercion.
190+
message = {
191+
"role": "assistant",
192+
"content": None,
193+
"tool_calls": [{"id": "x", "type": "function", "function": function_value}],
194+
}
195+
196+
with pytest.raises(ValueError, match=r"tool_calls\[0\]\.function"):
197+
parse_chat_message_content(message, mock_mm_data_tracker)
198+
199+
@pytest.mark.parametrize("tool_call", [123, "foo", None])
200+
def test_assistant_message_tool_call_non_object_item(
201+
self, mock_mm_data_tracker, tool_call
202+
) -> None:
203+
"""A tool_call that is not an object must raise a clean ValueError."""
204+
message = {
205+
"role": "assistant",
206+
"content": None,
207+
"tool_calls": [tool_call],
208+
}
209+
210+
with pytest.raises(ValueError, match=r"tool_calls\[0\]"):
211+
parse_chat_message_content(message, mock_mm_data_tracker)
212+
213+
def test_assistant_message_tool_call_preserves_extra_fields(self, mock_mm_data_tracker) -> None:
214+
# Extra fields and omitted optional id/type are preserved through the lenient fallback
215+
# validation (the tau2-bench leniency).
216+
message = {
217+
"role": "assistant",
218+
"content": None,
219+
"tool_calls": [
220+
{
221+
# No `id`/`type`; an arbitrary extra field.
222+
"index": 0,
223+
"function": {
224+
"name": "get_weather",
225+
"arguments": '{"location": "NYC"}',
226+
"extra": "kept",
227+
},
228+
}
229+
],
230+
}
231+
232+
result = parse_chat_message_content(message, mock_mm_data_tracker)
233+
234+
assert result["tool_calls"] == [
235+
{
236+
"index": 0,
237+
"function": {
238+
"name": "get_weather",
239+
"arguments": {"location": "NYC"},
240+
"extra": "kept",
241+
},
242+
}
243+
]
244+
120245
def test_assistant_message_with_multiple_tool_calls(self, mock_mm_data_tracker):
121246
"""Test parsing an assistant message with multiple tool calls."""
122247
message = {

0 commit comments

Comments
 (0)