fix(sdk/python): coerce complex pydantic type hints in reasoner args - #1035
fix(sdk/python): coerce complex pydantic type hints in reasoner args#10357vignesh wants to merge 4 commits into
Conversation
…gent-Field#1034) Argument coercion only handled a bare model or a 2-arg Optional[model]. Complex hints fell through as raw dicts: - unions of 3+ (M1 | M2 | None) - containers of models (list[M], Sequence[M | None]) - unions of container types (list[M1] | list[M2] | None) It also silently swallowed validation errors due to a Pydantic v2 ValidationError constructor mismatch, returning the raw dict instead of surfacing the failure. Fix: - type_hint_involves_model(): recurses through Union/list/Sequence/tuple/ dict args to detect a model anywhere in the hint. - _convert_with_type_hint(): validates via pydantic TypeAdapter, which handles any nested/union/optional/container shape losslessly; hints with no model are returned untouched (preserves plain int/str/dict pass-through). - convert_function_args now propagates validation errors (wrapped with the parameter name) instead of hiding them; non-validation errors still fall back to original args for backward compatibility. - convert_dict_to_model uses model_class.model_validate (correct v2 API). - should_convert_args uses the same recursive detection so conversion triggers for the new shapes. Adds tests for each reported case plus non-model pass-through and validation-error propagation.
Performance
✓ No regressions detected |
📊 Coverage gateThresholds from
✅ Gate passedNo surface regressed past the allowed threshold and the aggregate stayed above the floor. |
📐 Patch coverage gateThreshold: 80% on lines this PR touches vs
✅ Patch gate passedEvery surface whose lines were touched by this PR has patch coverage at or above the threshold. |
santoshkumarradha
left a comment
There was a problem hiding this comment.
Thanks for chasing the nested-type coercion gaps here. I found one behavior regression that needs to be fixed before this is safe to merge: invalid model payloads now get rethrown as ValueError, but the current SDK call sites still only intercept pydantic.ValidationError when they convert reasoner and skill inputs. That means a bad payload can now miss the existing safe-validation path and either fall back to the raw dict or surface through the wrong error path. If you preserve ValidationError here, or update the callers in the same PR, this looks close.
| except ValidationError as e: | ||
| # Add parameter context to the error, preserving the original | ||
| # ValidationError as the cause. | ||
| raise ValueError( |
There was a problem hiding this comment.
This changes the surfaced exception type from ValidationError to ValueError, but the existing callers in agent.py and decorators.py only catch ValidationError around convert_function_args(). With this change, invalid model payloads can skip the current safe-input handling path. Can we preserve ValidationError here, or update those callers in the same PR?
…eld#1034 review) Addresses review feedback on PR Agent-Field#1035. The rewrite wrapped validation failures as ValueError, but the reasoner/skill call sites in agent.py and decorators.py intercept pydantic.ValidationError specifically to route bad payloads through their safe-validation path (_HandlerInputError, avoiding stack-trace exposure in 422s). Wrapping as ValueError let a bad payload miss that handler and fall back to the raw dict. Let the ValidationError from TypeAdapter propagate unchanged so the existing callers keep intercepting it. Tests now assert ValidationError explicitly.
|
Good catch - fixed in 656bbd9. You're right: wrapping the failure as I went with option 1 (preserve Verified end to end: def f(m: MyModel): ...
convert_function_args(f, (), {"m": {"x": "bad"}}) # -> raises pydantic.ValidationError
def g(items: list[MyModel]): ...
convert_function_args(g, (), {"items": [{"x": "bad"}]}) # -> raises pydantic.ValidationErrorBoth the direct-model and nested-container error paths now surface |
Summary
Fixes #1034. Reasoner/skill argument coercion in the Python SDK only handled a bare Pydantic model or a 2-arg
Optional[model]. Complex type hints fell through and the caller received rawdicts instead of model instances.Type of change
Reproduction
Before this change, these all left the value as a raw
dict/ list of dicts:Root cause
pydantic_utils.pydetected a model only viais_pydantic_model(actual_type)after unwrapping a strict 2-argOptional. A 3+ arg union, or a container whose element is a model, never matched, soconvert_dict_to_modelwas never called. Separately,convert_function_argswrapped Pydantic v2ValidationErrorwith keyword args its v2 constructor rejects, so theexcept Exceptionfallback swallowed real validation failures and returned the raw dict.Fix
type_hint_involves_model()recurses throughUnion/list/Sequence/tuple/dictargs to detect a model anywhere in a hint._convert_with_type_hint()validates viapydantic.TypeAdapter(hint).validate_python(value), which resolves any nested / union / optional / container shape losslessly. Hints with no model are returned untouched, so plainint/str/dictparams still pass through unchanged.convert_function_argsnow propagates validation errors (wrapped with the parameter name) instead of hiding them; non-validation errors (e.g. unresolved forward refs) still fall back to the original args.convert_dict_to_modelusesmodel_class.model_validate(the correct v2 API).should_convert_argsuses the same recursive detection so conversion triggers for the new shapes.Test plan
cd sdk/python && python -m pytest tests/test_pydantic_utils.py- 15 passedpython -m pytest tests/test_skill_pydantic_models.py tests/test_decorators.py- passlitellm-not-installed and live-server environmental issues, pre-existing)python -m ruff checkandruff format --checkon the changed files - cleanNew tests cover: multi-arg union of models,
list[model], union oflist[model],Sequence[model | None], nested-model roundtrip, container validation-error propagation, and non-model pass-through for complex hints.Checklist
Related issues / PRs
Fixes #1034