Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 29 additions & 4 deletions src/openai/lib/_parsing/_completions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Redact malformed response contents from debug logs

When openai.lib.parsing debug logging is enabled and Pydantic rejects structured content or tool arguments, interpolating exc renders the ValidationError, whose text can include the offending input_value; this can copy customer response data or credentials embedded in model output into application logs. Both new debug statements should log only sanitized metadata such as the exception type or error codes, not the exception string.

AGENTS.md reference: AGENTS.md:L26-L30

Useful? React with 👍 / 👎.

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,
Expand All @@ -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,
),
Expand Down Expand Up @@ -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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Only suppress JSON syntax failures

When the response is valid JSON but fails the supplied model's schema or a custom Pydantic validator, this broad catch now returns parsed=None instead of preserving the prior ValidationError. Custom validator constraints are not necessarily represented in the JSON schema sent to the API, so this can silently discard otherwise complete responses and make a contract violation indistinguishable from absent or refused content; inspect the validation error and suppress only JSON decoding failures. The equivalent catch around tool argument parsing has the same problem.

Useful? React with 👍 / 👎.

log.debug("Failed to parse structured-output content: %s", exc)
return None


def has_parseable_input(
*,
response_format: type | ResponseFormatParam | Omit,
Expand Down
77 changes: 77 additions & 0 deletions tests/lib/chat/test_completions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"