Repository navigation
fix(adapters): keep pydantic field constraints in structured outputs - #10326
Rodrigo-Palma wants to merge 3 commits into
Conversation
|
|
Good catch, and it reproduces exactly as described: Fixed in acfda15, and the root cause was broader than the alias. Copying the whole So rather than stripping attributes one by one, the fix now re-attaches only what the constraints actually live in. annotation = Annotated[(annotation, *field.metadata)] if field.metadata else annotation
fields[name] = (annotation, default)Nothing else from the I deliberately did not take the other route you mentioned (normalizing aliases back to field names in the parser). Making the adapter alias-aware is a feature, not part of a constraints fix, and it would change what responses DSPy accepts. This PR now leaves alias behavior exactly as it was on Schema after the fix, with constraints, an alias and a description all declared on the same signature: Three tests added for this: the alias does not reach the schema or |
|
Thanks for the follow-up. This addresses the concern: reattaching only Tip: You can customize Greptile's behavior for this repo with |
|
The workflow run here is waiting on maintainer approval (first-time contributor), so I ran this repo's full CI on my fork to have the result available in advance: Lint, Test, and Build run — All 19 jobs green: |
_get_structured_outputs_response_format rebuilt every output field as a bare (annotation, default) pair, which discards the FieldInfo and with it every constraint declared on the OutputField: ge/le/gt/lt, multiple_of, pattern and min_length/max_length. The derived response format kept the type and dropped the bound, so a provider was never asked to honor it. DSPy already consumes this same metadata on the prompt path: move_kwargs runs _translate_pydantic_field_constraints and stores the result in json_schema_extra["constraints"], which the adapters render as natural language. The same OutputField(ge=0, le=1) therefore stated its bound to the model in JSON mode and stated nothing at all in structured-outputs mode. Rebuild from the FieldInfo and clear only DSPy's own prompt metadata, which is not part of the wire schema. Defaults carry over with the FieldInfo, so the required list and additionalProperties enforcement are unchanged, and a field declared without constraints produces the same schema as before.
Copying the FieldInfo carried more than the constraints into the wire schema. An output field's alias replaced its name there, so a provider response that was valid for the schema was discarded by JSONAdapter.parse, which keys off the signature field names, and the call failed with AdapterParseError. A field's description leaked the same way, which is a separate change from preserving constraints and does not belong in this one. field.metadata holds exactly the declared constraints, so re-attach those through Annotated and leave everything else alone. The schema keeps the signature field names, gains no description, and still carries every bound.
|
Rebased on main. #10463 added |
acfda15 to
d5c9046
Compare
Comments Outside DiffThese findings could not be posted inline.
|
|
@greptile-apps the alias finding is not introduced here: on |
|
You’re right. This is not introduced by the PR: on The current patch only reattaches |
📝 Changes Description
Fixes #10195.
_get_structured_outputs_response_formatrebuilt every output field as a bare(annotation, default)pair:That discards the
FieldInfo, and with it every constraint declared on the field —ge/le/gt/lt,multiple_of,pattern,min_length/max_length. The derivedresponse format kept the type and dropped the bound, so the provider was never asked to
honor it:
Why this reads as a loss rather than a deliberate choice (the issue asks): DSPy
already consumes this exact metadata on the other path.
move_kwargsruns_translate_pydantic_field_constraints(**kwargs)and stores the result injson_schema_extra["constraints"], which the prompt-side adapters render as naturallanguage for the model. So the same
OutputField(ge=0, le=1)states its bound to themodel in JSON mode and states nothing at all in structured-outputs mode. The framework
understands the constraint; only the derivation drops it.
The change: rebuild from the
FieldInfoand clear only DSPy's own prompt metadata,which is not part of the wire schema. The default carries over with the
FieldInfo, sothe
requiredlist andadditionalProperties: falseenforcement are unchanged, and afield declared without constraints produces byte-for-byte the same schema as before.
Tests (
tests/adapters/test_json_adapter.py, 6 added): numeric bounds, exclusivebounds and string constraints are preserved — these three fail on
main; DSPy metadatadoes not leak into the schema;
required/additionalPropertiesstill enforced; anunconstrained field's schema is unchanged.
✅ Contributor Checklist
Full suite as CI runs it:
pytest -m 'not extra and not deno' -n auto→ 1256 passed,253 skipped, 2 xfailed.
ruff check --fix-only --diff --exit-non-zero-on-fixclean.Structured-outputs backends differ in which JSON Schema validation keywords they accept.
The keywords emitted here (
minimum,maximum,exclusiveMinimum,exclusiveMaximum,multipleOf,pattern,minLength,maxLength) are in OpenAI's documented supportedsubset, but a provider that rejects them would now fail where it previously succeeded by
silently dropping the user's constraint. If you would rather sanitize per provider than
emit them unconditionally, that split belongs at this same seam and I am happy to add it —
I did not want to guess at a provider allowlist.
Disclosure: I used an AI coding assistant to help explore the code and draft this. I
reproduced the behavior, wrote and verified the tests, and understand the change.