diff --git a/dspy/adapters/json_adapter.py b/dspy/adapters/json_adapter.py index 286f3e4aca..bf23f5a730 100644 --- a/dspy/adapters/json_adapter.py +++ b/dspy/adapters/json_adapter.py @@ -278,7 +278,9 @@ def enforce_required(schema_part: dict): """ Recursively ensure that: - for any object schema, a "required" key is added with all property names (or [] if no properties) - - additionalProperties is set to False regardless of the previous value. + - additionalProperties is set to False for fixed-property objects and open-ended maps, + while a typed additionalProperties (dict/map value schema) is preserved and recursed + into, mirroring OpenAI's own strict-mode converter (openai/lib/_pydantic.py:49-51). - the same enforcement is run for nested arrays and definitions. """ if schema_part.get("type") == "object": @@ -291,10 +293,21 @@ def enforce_required(schema_part: dict): if isinstance(sub_schema, dict): enforce_required(sub_schema) else: - # For objects with no properties (should not happen normally but a fallback). - schema_part["properties"] = {} - schema_part["required"] = [] - schema_part["additionalProperties"] = False + # Objects with no `properties` key are map/dict types in pydantic + # (additionalProperties describes the value schema). OpenAI's own + # strict converter (openai/lib/_pydantic.py:49-51) preserves a + # typed additionalProperties rather than collapsing it to false, + # so mirror that: preserve and recurse into typed value schemas. + # For open-ended dicts (dict[str, Any] -> additionalProperties: true) + # collapse to false, preserving the prior closed-empty-object + # behaviour (semantically identical to today; only the vacuous + # `properties: {}` key is dropped on the wire). + ap = schema_part.get("additionalProperties") + if isinstance(ap, dict): + enforce_required(ap) + elif ap is True or "additionalProperties" not in schema_part: + schema_part["additionalProperties"] = False + schema_part.setdefault("required", []) if schema_part.get("type") == "array" and isinstance(schema_part.get("items"), dict): enforce_required(schema_part["items"]) # Also enforce in any nested definitions / $defs. diff --git a/tests/adapters/test_json_adapter.py b/tests/adapters/test_json_adapter.py index 63e07e1710..f2da4880ad 100644 --- a/tests/adapters/test_json_adapter.py +++ b/tests/adapters/test_json_adapter.py @@ -1716,3 +1716,186 @@ class OptionalOutputSignature(dspy.Signature): with pytest.raises(AdapterParseError): adapter.parse(OptionalOutputSignature, '{"note": "present"}') + + +# --------------------------------------------------------------------------- +# Regression tests for `enforce_required` preserving typed dict/map fields on +# the OpenAI Structured Outputs ("strict") path. See b8d9092: the no-`properties` +# branch used to unconditionally rewrite any `type: object` schema lacking a +# `properties` key to `{"properties": {}, "required": [], "additionalProperties": +# false}`, which destroyed the typed `additionalProperties` pydantic emits for +# dict/map fields (e.g. `dict[str, int]` -> `{"type": "object", +# "additionalProperties": {"type": "integer"}}`). OpenAI's own strict converter +# (openai/lib/_pydantic.py:49-51) preserves a typed `additionalProperties`, so we +# mirror that and only collapse `additionalProperties: true` (open-ended dicts) +# to `false`. +# --------------------------------------------------------------------------- + + +def _structured_output_model(signature): + from dspy.adapters.json_adapter import _get_structured_outputs_response_format + + return _get_structured_outputs_response_format(signature) + + +def _capture_response_format(signature): + dspy.configure(lm=dspy.LM(model="openai/gpt-4o", cache=False), adapter=dspy.JSONAdapter()) + program = dspy.Predict(signature) + with mock.patch("litellm.completion") as mock_completion: + try: + program(q="x") + except Exception: + pass + _, call_kwargs = mock_completion.call_args + return call_kwargs["response_format"] + + +def test_enforce_required_preserves_typed_map_in_array_items(): + """list[dict[str, int]] must ship a typed map (additionalProperties: {integer}) + on the strict path, not the closed empty object.""" + + class S(dspy.Signature): + q: str = dspy.InputField() + metadata: list[dict[str, int]] = dspy.OutputField() + + schema = _structured_output_model(S).model_json_schema() + items = schema["properties"]["metadata"]["items"] + assert items == { + "type": "object", + "additionalProperties": {"type": "integer"}, + "required": [], + } + # No vacuous `properties: {}` key is installed (it would forbid real entries). + assert "properties" not in items + + +def test_enforce_required_collapses_open_ended_nested_map(): + """list[dict[str, Any]] (open-ended) stays a closed empty object: + additionalProperties: false; only the vacuous properties: {} key is dropped.""" + + from typing import Any + + class S(dspy.Signature): + q: str = dspy.InputField() + metadata: list[dict[str, Any]] = dspy.OutputField() + + schema = _structured_output_model(S).model_json_schema() + items = schema["properties"]["metadata"]["items"] + assert items["type"] == "object" + assert items["additionalProperties"] is False + assert items["required"] == [] + assert "properties" not in items + + +def test_enforce_required_preserves_nested_typed_map(): + """dict[str, dict[str, int]] recursion: the inner typed map is preserved.""" + + class S(dspy.Signature): + q: str = dspy.InputField() + nested: list[dict[str, dict[str, int]]] = dspy.OutputField() + + schema = _structured_output_model(S).model_json_schema() + outer = schema["properties"]["nested"]["items"] + assert outer["additionalProperties"]["additionalProperties"] == {"type": "integer"} + + +def test_enforce_required_preserves_dict_subfield_in_pydantic_submodel(): + """A dict[str, int] sub-field of a pydantic BaseModel output field reaches + enforce_required via $defs recursion and must be preserved.""" + + class Inner(pydantic.BaseModel): + kv: dict[str, int] + + class S(dspy.Signature): + q: str = dspy.InputField() + out: Inner = dspy.OutputField() + + schema = _structured_output_model(S).model_json_schema() + kv = schema["$defs"]["Inner"]["properties"]["kv"] + assert kv["type"] == "object" + assert kv["additionalProperties"] == {"type": "integer"} + assert kv["required"] == [] + + +def test_enforce_required_keeps_fixed_property_object_behaviour(): + """Inverse regression: fixed-property pydantic submodels still get + required = [all keys] and additionalProperties = false.""" + + class Inner(pydantic.BaseModel): + a: int + b: str + + class S(dspy.Signature): + q: str = dspy.InputField() + out: Inner = dspy.OutputField() + + schema = _structured_output_model(S).model_json_schema() + inner = schema["$defs"]["Inner"] + assert set(inner["required"]) == {"a", "b"} + assert inner["additionalProperties"] is False + + +def test_enforce_required_keeps_scalar_array_items(): + """Inverse regression: list[int] still ships items: {type: integer}.""" + + class S(dspy.Signature): + q: str = dspy.InputField() + scores: list[int] = dspy.OutputField() + + schema = _structured_output_model(S).model_json_schema() + assert schema["properties"]["scores"]["items"] == {"type": "integer"} + + +def test_typed_nested_dict_stays_on_structured_output_path(): + """End-to-end: list[dict[str, int]] routes through the strict path (a pydantic + model is sent), not the json_object fallback.""" + + class S(dspy.Signature): + q: str = dspy.InputField() + metadata: list[dict[str, int]] = dspy.OutputField() + + response_format = _capture_response_format(S) + assert issubclass(response_format, pydantic.BaseModel) + items = response_format.model_json_schema()["properties"]["metadata"]["items"] + assert items["additionalProperties"] == {"type": "integer"} + + +def test_top_level_dict_still_routes_to_json_object(): + """Inverse regression: top-level dict[str, int] still routes to the json_object + fallback via _has_open_ended_mapping (the guard is untouched by the fix).""" + + class S(dspy.Signature): + q: str = dspy.InputField() + metadata: dict[str, int] = dspy.OutputField() + + response_format = _capture_response_format(S) + assert response_format == {"type": "json_object"} + + +def test_typed_nested_dict_parse_preserves_populated_entries(): + """End-to-end parse: now that the on-wire schema allows real entries, a populated + emission parses back into the declared typed dicts (no silent data loss).""" + + class S(dspy.Signature): + q: str = dspy.InputField() + metadata: list[dict[str, int]] = dspy.OutputField() + + adapter = dspy.JSONAdapter() + parsed = adapter.parse(S, '{"metadata": [{"score": 3, "rank": 1}, {"score": 9}]}') + assert parsed["metadata"] == [{"score": 3, "rank": 1}, {"score": 9}] + + +def test_on_wire_typed_map_schema_preserved_after_openai_strict_converter(): + """The schema the OpenAI strict API receives (after litellm's + to_strict_json_schema conversion) preserves the typed additionalProperties.""" + pytest.importorskip("openai") + from openai.lib._pydantic import to_strict_json_schema + + class S(dspy.Signature): + q: str = dspy.InputField() + metadata: list[dict[str, int]] = dspy.OutputField() + + model = _structured_output_model(S) + on_wire_items = to_strict_json_schema(model)["properties"]["metadata"]["items"] + assert on_wire_items["additionalProperties"] == {"type": "integer"} + assert on_wire_items["type"] == "object"