diff --git a/src/openai/lib/_parsing/_completions.py b/src/openai/lib/_parsing/_completions.py index 7a1bded1de..756aea6d7f 100644 --- a/src/openai/lib/_parsing/_completions.py +++ b/src/openai/lib/_parsing/_completions.py @@ -109,15 +109,25 @@ def parse_chat_completion( for tool_call in message.tool_calls: if tool_call.type == "function": tool_call_dict = tool_call.to_dict() + try: + parsed_arguments = parse_function_tool_arguments( + input_tools=input_tools, function=tool_call.function + ) + except (pydantic.ValidationError, json.JSONDecodeError) as exc: + # The model returned a function-call whose arguments are not + # valid JSON for the declared tool schema (e.g. truncated by a + # stream cut-off). Surface the call with parsed_arguments=None + # instead of letting the exception escape the best-effort parse + # boundary. See issue #1763. + log.debug("Failed to parse tool call arguments: %s", exc) + parsed_arguments = None tool_calls.append( construct_type_unchecked( value={ **tool_call_dict, "function": { **cast(Any, tool_call_dict["function"]), - "parsed_arguments": parse_function_tool_arguments( - input_tools=input_tools, function=tool_call.function - ), + "parsed_arguments": parsed_arguments, }, }, type_=ParsedFunctionToolCall, @@ -143,7 +153,7 @@ def parse_chat_completion( **choice.to_dict(), "message": { **message.to_dict(), - "parsed": maybe_parse_content( + "parsed": _safe_maybe_parse_content( response_format=response_format, message=message, ), @@ -198,6 +208,21 @@ def maybe_parse_content( return None +def _safe_maybe_parse_content( + *, + response_format: type[ResponseFormatT] | ResponseFormatParam | Omit, + message: ChatCompletionMessage | ParsedChatCompletionMessage[object], +) -> ResponseFormatT | None: + """Same contract as ``maybe_parse_content`` but catches JSON-decode and + pydantic validation errors so the best-effort parsing boundary in + ``parse_chat_completion`` never lets them escape. See issue #1763.""" + try: + return maybe_parse_content(response_format=response_format, message=message) + except (pydantic.ValidationError, json.JSONDecodeError) as exc: + log.debug("Failed to parse structured-output content: %s", exc) + return None + + def has_parseable_input( *, response_format: type | ResponseFormatParam | Omit, diff --git a/tests/lib/chat/test_completions.py b/tests/lib/chat/test_completions.py index 0d783b2ce9..c7592b50cc 100644 --- a/tests/lib/chat/test_completions.py +++ b/tests/lib/chat/test_completions.py @@ -1021,3 +1021,80 @@ def test_parse_method_in_sync(sync: bool, client: OpenAI, async_client: AsyncOpe checking_client.chat.completions.parse, exclude_params={"response_format", "stream"}, ) + + +class _TruncatedJSONModel(BaseModel): + city: str + temperature: float + units: Literal["c", "f"] + + +def _build_truncated_chat_completion(content: str) -> openai.types.chat.ChatCompletion: + """Construct a minimal ChatCompletion whose message content is intentionally + truncated / malformed JSON. Used by the regression tests for #1763.""" + return openai.types.chat.ChatCompletion.model_validate( + { + "id": "chatcmpl-truncated-fixture", + "object": "chat.completion", + "created": 1727346142, + "model": "gpt-4o-2024-08-06", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": content, + "refusal": None, + }, + "logprobs": None, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 79, + "completion_tokens": 14, + "total_tokens": 93, + }, + } + ) + + +def test_parse_chat_completion_does_not_raise_on_truncated_json_content() -> None: + """#1763: chat.completions.parse must not propagate pydantic.ValidationError + or json.JSONDecodeError when the model returns truncated JSON in the content + field. The parse helper should treat such a response as unparseable and + surface ``message.parsed = None`` instead of letting the exception escape.""" + + from openai.lib._parsing._completions import parse_chat_completion + + # The trailing brace is deliberately missing — a pydantic model_parse_json call + # on this string raises ValidationError today. + truncated = '{"city":"San Francisco","temperature":65,"units":' + + raw = _build_truncated_chat_completion(truncated) + + parsed = parse_chat_completion( + response_format=_TruncatedJSONModel, + input_tools=openai._types.omit, + chat_completion=raw, + ) + + assert parsed.choices[0].message.parsed is None + assert parsed.choices[0].message.content == truncated + + +def test_parse_chat_completion_does_not_raise_on_garbage_json_content() -> None: + """#1763 follow-up: garbage / non-JSON content must not crash parse either.""" + + from openai.lib._parsing._completions import parse_chat_completion + + raw = _build_truncated_chat_completion("not even close to json") + + parsed = parse_chat_completion( + response_format=_TruncatedJSONModel, + input_tools=openai._types.omit, + chat_completion=raw, + ) + + assert parsed.choices[0].message.parsed is None + assert parsed.choices[0].message.content == "not even close to json"