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
39 changes: 38 additions & 1 deletion lib/crewai/src/crewai/utilities/pydantic_schema_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
TypedDict,
Union,
cast,
get_args,
get_origin,
)
import uuid

Expand Down Expand Up @@ -1042,7 +1044,20 @@ def _json_schema_to_pydantic_field(
elif len(allowed_schemes) == 1 and allowed_schemes[0] == "file":
pydantic_type = FileUrl

type_ = pydantic_type
# `type_` can be a Union built from a list-form `type` (or anyOf/oneOf)
# rather than a plain `str`, e.g. `{"type": ["string", "null"],
# "format": "date-time"}`. Replacing the whole thing with
# `pydantic_type` would silently drop the other members (null,
# non-string alternatives) instead of just narrowing the string one.
if type_ is str:
type_ = pydantic_type
elif get_origin(type_) is Union:
type_ = Union[ # noqa: UP007
tuple(
pydantic_type if member is str else member
for member in get_args(type_)
)
]

if isinstance(type_, type) and issubclass(type_, str):
if "minLength" in json_schema:
Expand Down Expand Up @@ -1215,6 +1230,28 @@ def _json_schema_to_pydantic_type(

type_ = json_schema.get("type")

if isinstance(type_, list):
# JSON Schema also allows "type" to be an array, e.g.
# {"type": ["string", "null"]} -- the .NET/System.Text.Json-style
# way of expressing a nullable field. Pydantic's own schema
# generation instead uses anyOf/oneOf for this (handled above), so
# external tool schemas (e.g. from a non-Python MCP server) are the
# main source of this form. Treat each entry the same way anyOf's
# members are handled just above: build a Union of the
# corresponding Python types. A single-element list collapses to
# that one type, matching typing.Union's own behavior.
member_types = [
_json_schema_to_pydantic_type(
{**json_schema, "type": member},
root_schema,
name_=f"{name_ or 'Union'}Option{i}",
enrich_descriptions=enrich_descriptions,
in_progress=in_progress,
)
for i, member in enumerate(type_)
]
return Union[tuple(member_types)] # noqa: UP007
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if type_ == "string":
return str
if type_ == "integer":
Expand Down
116 changes: 116 additions & 0 deletions lib/crewai/tests/utilities/test_pydantic_schema_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,122 @@ def test_oneof(self) -> None:
assert Model(value="hello").value == "hello"
assert Model(value=3.14).value == pytest.approx(3.14)

def test_type_array_nullable_string_with_format(self) -> None:
"""type: ["string", "null"] -- the .NET/System.Text.Json-style way
of expressing an optional field, as opposed to Pydantic's own
anyOf-based form. Seen in real MCP tool schemas from non-Python
servers (e.g. Equibles' ListCompanyDocuments startDate/endDate
filters). The format="date-time" here (also straight from that
real schema) is applied by the existing FORMAT_TYPE_MAP logic once
the list-form type no longer raises, so the field lands as a real
datetime rather than str -- that's the pre-existing, correct
behavior for any date-time-formatted field, not something this fix
changes."""
schema = {
"type": "object",
"properties": {
"startDate": {
"description": "Optional start date filter in YYYY-MM-DD format",
"type": ["string", "null"],
"format": "date-time",
"default": None,
},
},
}
Model = create_model_from_schema(schema)
assert Model(startDate="2026-01-01").startDate == datetime.datetime(
2026, 1, 1
)
assert Model(startDate=None).startDate is None
assert Model().startDate is None

def test_type_array_nullable_string_no_format(self) -> None:
schema = {
"type": "object",
"properties": {
"note": {"type": ["string", "null"]},
},
}
Model = create_model_from_schema(schema)
assert Model(note="hello").note == "hello"
assert Model(note=None).note is None
assert Model().note is None

def test_type_array_multiple_non_null(self) -> None:
schema = {
"type": "object",
"properties": {
"value": {"type": ["string", "integer", "null"]},
},
}
Model = create_model_from_schema(schema)
assert Model(value="hello").value == "hello"
assert Model(value=42).value == 42
assert Model(value=None).value is None

def test_type_array_single_element(self) -> None:
schema = {
"type": "object",
"properties": {"value": {"type": ["string"]}},
"required": ["value"],
}
Model = create_model_from_schema(schema)
assert Model(value="hello").value == "hello"

def test_type_array_required_nullable_string_with_format(self) -> None:
"""A required-but-nullable formatted field, e.g. `{"type":
["string", "null"], "format": "date-time"}` inside a "required"
list -- a valid JSON Schema shape meaning the key must be present
but its value may be null. Before this fix, the FORMAT_TYPE_MAP
override in `_json_schema_to_pydantic_field` replaced the whole
`Union[datetime, None]` with plain `datetime`, so passing `None`
would fail validation even though the schema explicitly allows it.
The `not is_required` Optional-rewrap at the end of that function
doesn't fire for required fields, so this case wasn't masked the
way the non-required version (test above) was.
"""
schema = {
"type": "object",
"properties": {
"startDate": {
"type": ["string", "null"],
"format": "date-time",
},
},
"required": ["startDate"],
}
Model = create_model_from_schema(schema)
assert Model(startDate="2026-01-01").startDate == datetime.datetime(
2026, 1, 1
)
assert Model(startDate=None).startDate is None
with pytest.raises(Exception):
Model()

def test_type_array_multiple_non_null_with_format(self) -> None:
"""A list-form type with more than one non-null member plus a
recognized format, e.g. `{"type": ["string", "integer", "null"],
"format": "date-time"}`. Before this fix, the FORMAT_TYPE_MAP
override collapsed the entire Union down to plain `datetime`,
silently dropping the `integer` alternative regardless of whether
the field was required. The fix narrows only the `str` member of
the union to the formatted type, leaving `integer` and `None`
alone.
"""
schema = {
"type": "object",
"properties": {
"value": {
"type": ["string", "integer", "null"],
"format": "date-time",
},
},
}
Model = create_model_from_schema(schema)
assert Model(value="2026-01-01").value == datetime.datetime(2026, 1, 1)
assert Model(value=42).value == 42
assert Model(value=None).value is None


class TestAllOfMerging:
def test_allof_merges_properties(self) -> None:
Expand Down