1111 ChatCompletionContentPartParam as OpenAIChatCompletionContentPartParam
1212from openai .types .chat import (ChatCompletionContentPartTextParam ,
1313 ChatCompletionMessageParam )
14+ from pydantic import BaseModel , ConfigDict , TypeAdapter , ValidationError
1415from transformers import AutoConfig
1516from 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
274378def _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
0 commit comments