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
Original file line number Diff line number Diff line change
Expand Up @@ -694,11 +694,40 @@ def _prepare_options(self, messages: Sequence[Message], options: Mapping[str, An
# response format
if response_format := options.get("response_format"):
if isinstance(response_format, dict):
run_options["response_format"] = response_format
run_options["response_format"] = self._normalize_response_format_dict(response_format)
else:
run_options["response_format"] = type_to_response_format_param(response_format)
Comment thread
exp-ouroborous marked this conversation as resolved.
return run_options

@staticmethod
def _normalize_response_format_dict(response_format: dict[str, Any]) -> dict[str, Any]:
"""Wrap raw JSON schemas (e.g. ``{"type": "object", ...}``) in the json_schema envelope.

Mirrors the Responses client's ``_convert_response_format`` handling of raw
schemas so both clients accept the same inputs; dicts already using a valid
Chat Completions response_format type pass through unchanged.
"""
format_type = response_format.get("type")
if format_type in {"json_schema", "json_object", "text"}:
return response_format
# Detect raw JSON Schema by primitive type or known schema keywords.
json_schema_keywords = {"properties", "anyOf", "oneOf", "allOf", "$ref", "$defs"}
json_schema_primitive_types = {"object", "array", "string", "number", "integer", "boolean", "null"}
if format_type in json_schema_primitive_types or (
format_type is None and any(k in response_format for k in json_schema_keywords)
):
schema = dict(response_format)
if schema.get("type") == "object" and "additionalProperties" not in schema:
schema["additionalProperties"] = False
# Pop title from schema since OpenAI strict mode rejects unknown keys;
# use it as the schema name in the envelope instead.
name = str(schema.pop("title", None) or "response")
return {
"type": "json_schema",
"json_schema": {"name": name, "schema": schema, "strict": True},
}
return response_format

def _parse_response_from_openai(self, response: ChatCompletion, options: Mapping[str, Any]) -> ChatResponse:
"""Parse a response from OpenAI into a ChatResponse."""
response_metadata = self._get_metadata_from_chat_response(response)
Expand Down
21 changes: 21 additions & 0 deletions python/packages/openai/tests/openai/test_openai_chat_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5567,6 +5567,27 @@ async def get_api_key() -> str:
True,
id="response_format_runtime_json_schema",
),
param(
"response_format",
{
"title": "WeatherDigest",
"type": "object",
"properties": {
"location": {"type": "string"},
"conditions": {"type": "string"},
"temperature_c": {"type": "number"},
"advisory": {"type": "string"},
},
"required": [
"location",
"conditions",
"temperature_c",
"advisory",
],
},
True,
id="response_format_raw_json_schema",
),
],
)
async def test_integration_options(
Expand Down

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this needs a integration test, because if I recall correctly there are differences in the format for the structured output between Chat Completions and Responses API's.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added in 47e3a08 — a response_format_raw_json_schema param in test_integration_options for both clients, passing the same bare WeatherDigest schema dict (with title, without additionalProperties) so the wrapping, title→name promotion, and additionalProperties: false injection paths are all exercised against the live APIs.

You're right that the wire formats differ — Chat Completions nests under response_format.json_schema while Responses flattens into text.format. Both new params pass live (verified locally with a real key): the identical raw input round-trips through both APIs and yields parsed structured output (response.value validated). The Responses-side param also adds live coverage for the pre-existing raw-schema handling in _convert_response_format, which previously had none — so if either implementation drifts, one of the twin params breaks.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

we have a marker for integration tests, because I want this to hit the actual service, see tests in this file that have:

@pytest.mark.flaky
@pytest.mark.integration
@skip_if_openai_integration_tests_disabled

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

They do use those markers — both response_format_raw_json_schema params were added inside the existing test_integration_options, which is decorated with @pytest.mark.flaky / @pytest.mark.integration / @skip_if_openai_integration_tests_disabled (lines 1771–1773 in this file, and the same in the chat client file), so they hit the actual service. Easy to miss in the diff since the decorators sit ~90 lines above the added params in the parametrize list.

Original file line number Diff line number Diff line change
Expand Up @@ -1586,6 +1586,59 @@ def test_response_format_dict_passthrough(openai_unit_test_env: dict[str, str])
assert prepared_options["response_format"] == custom_format


def test_response_format_raw_schema_dict_is_wrapped(openai_unit_test_env: dict[str, str]) -> None:
"""A raw JSON-Schema dict is wrapped in the json_schema envelope (parity with the Responses client)."""
client = OpenAIChatCompletionClient()

messages = [Message(role="user", contents=["test"])]
raw_schema = {
"type": "object",
"properties": {"word": {"type": "string"}},
"required": ["word"],
}

prepared_options = client._prepare_options(messages, {"response_format": raw_schema})

assert prepared_options["response_format"] == {
"type": "json_schema",
"json_schema": {
"name": "response",
"schema": {
"type": "object",
"properties": {"word": {"type": "string"}},
"required": ["word"],
"additionalProperties": False,
},
"strict": True,
},
}


def test_response_format_raw_schema_title_becomes_name(openai_unit_test_env: dict[str, str]) -> None:
"""A raw schema's title is popped into the envelope name (strict mode rejects unknown keys)."""
client = OpenAIChatCompletionClient()

messages = [Message(role="user", contents=["test"])]
raw_schema = {"type": "object", "title": "Word", "properties": {"word": {"type": "string"}}}

prepared_options = client._prepare_options(messages, {"response_format": raw_schema})

wrapped = prepared_options["response_format"]
assert wrapped["json_schema"]["name"] == "Word"
assert "title" not in wrapped["json_schema"]["schema"]


def test_response_format_json_object_dict_passthrough(openai_unit_test_env: dict[str, str]) -> None:
"""Valid non-json_schema response_format types still pass through unchanged."""
client = OpenAIChatCompletionClient()

messages = [Message(role="user", contents=["test"])]

prepared_options = client._prepare_options(messages, {"response_format": {"type": "json_object"}})

assert prepared_options["response_format"] == {"type": "json_object"}


def test_parse_response_with_dict_response_format(openai_unit_test_env: dict[str, str]) -> None:
"""Chat completions should parse dict response_format values into response.value."""
client = OpenAIChatCompletionClient()
Expand Down Expand Up @@ -1786,6 +1839,27 @@ class OutputStruct(BaseModel):
True,
id="response_format_runtime_json_schema",
),
param(
"response_format",
{
"title": "WeatherDigest",
"type": "object",
"properties": {
"location": {"type": "string"},
"conditions": {"type": "string"},
"temperature_c": {"type": "number"},
"advisory": {"type": "string"},
},
"required": [
"location",
"conditions",
"temperature_c",
"advisory",
],
},
True,
id="response_format_raw_json_schema",
),
],
)
async def test_integration_options(
Expand Down