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
24 changes: 23 additions & 1 deletion mellea/backends/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,15 @@ def _build_pydantic_type_from_schema(schema: dict[str, Any]) -> Any:

return (result | None) if has_null else result

# Handle enum constraint (e.g. Literal["read", "write"] on a plain field)
if "enum" in schema:
enum_vals = tuple(schema["enum"])
return Literal[enum_vals] # type: ignore

# Handle const constraint (e.g. a bare {"type": "string", "const": "cat"})
if "const" in schema:
return Literal[schema["const"]] # type: ignore

# Simple type mapping
return JSON_TYPE_TO_PYTHON.get(json_type, Any)

Expand Down Expand Up @@ -1413,18 +1422,31 @@ def convert_function_to_ollama_tool(
# This now handles Optional[primitive] types correctly
if "anyOf" in v:
types = {t.get("type", "string") for t in v.get("anyOf")}
# Collect enum values from each non-null anyOf branch
# (handles Optional[Literal[...]] which Pydantic emits as anyOf)
enum_values: list | None = None
for sub in v.get("anyOf", []):
if sub.get("type") == "null":
continue
if "enum" in sub:
enum_values = list(sub["enum"])
break
else:
types = {v.get("type", "string")}
enum_values = list(v["enum"]) if "enum" in v else None

if "null" in types:
if k in schema.get("required", []):
schema["required"].remove(k)
types.discard("null")

schema["properties"][k] = {
prop: dict = {
"description": parsed_docstring.get(k, ""),
"type": ", ".join(types),
}
if enum_values is not None:
prop["enum"] = enum_values
schema["properties"][k] = prop

# Final pass: recursively inline all remaining $refs at any depth.
# This catches dangling references in nested model properties that weren't
Expand Down
10 changes: 10 additions & 0 deletions test/backends/test_discriminated_union_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,16 @@ def test_strict_rejects_missing_discriminator(self):
with pytest.raises(ValidationError):
validate_tool_arguments(mt, {"pet": {"name": "Rex"}}, strict=True)

def test_strict_rejects_invalid_discriminator_value(self):
"""A kind value outside the allowed set must be rejected."""
mt = MelleaTool.from_callable(act)
with pytest.raises(ValidationError):
validate_tool_arguments(
mt,
{"pet": {"kind": "horse", "name": "Bob", "breed": "lab"}},
strict=True,
)

def test_optional_accepts_omitted(self):
"""The optional variant accepts the parameter being omitted."""
mt = MelleaTool.from_callable(act_optional)
Expand Down
44 changes: 44 additions & 0 deletions test/backends/test_pydantic_tool_parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -1009,5 +1009,49 @@ def create_person(person: Person) -> str:
assert validated_no_addr["person"]["address"] is None


class TestLiteralFields:
"""Schema generation and validation for plain Literal[...] tool parameters."""

def test_strict_rejects_value_outside_literal(self):
"""validate_tool_arguments must reject values not in Literal[...] when strict=True."""
from typing import Literal

from pydantic import ValidationError

def file_op(path: str, mode: Literal["read", "write"]) -> str:
"""Perform a file operation.

Args:
path: the file path
mode: the operation mode
"""
return "ok"

mt = MelleaTool.from_callable(file_op)
with pytest.raises(ValidationError):
validate_tool_arguments(
mt, {"path": "/etc/passwd", "mode": "delete"}, strict=True
)

def test_strict_accepts_value_inside_literal(self):
"""validate_tool_arguments must accept values that are in Literal[...]."""
from typing import Literal

def file_op(path: str, mode: Literal["read", "write"]) -> str:
"""Perform a file operation.

Args:
path: the file path
mode: the operation mode
"""
return "ok"

mt = MelleaTool.from_callable(file_op)
result = validate_tool_arguments(
mt, {"path": "/tmp/file.txt", "mode": "read"}, strict=True
)
assert result["mode"] == "read"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think it's worth adding a test for single value constraints.

Suggested change
assert result["mode"] == "read"
assert result["mode"] == "read"
def test_strict_rejects_value_outside_single_value_literal(self):
"""A single-value Literal[...] (emitted as const) must also be enforced."""
from typing import Literal
from pydantic import ValidationError
def tag_op(name: str, kind: Literal["cat"]) -> str:
"""Tag something.
Args:
name: the name
kind: the kind
"""
return "ok"
mt = MelleaTool.from_callable(tag_op)
# The allowed value must survive into the schema the backend sees.
props = mt.as_json_tool["function"]["parameters"]["properties"]
assert props["kind"].get("enum") == ["cat"]
with pytest.raises(ValidationError):
validate_tool_arguments(
mt, {"name": "Bob", "kind": "horse"}, strict=True
)



if __name__ == "__main__":
pytest.main([__file__, "-v"])
Loading