Skip to content

Commit 7d8c57a

Browse files
authored
Merge branch 'main' into cursor/fix-miprov2-compile-seed-zero-88e7
2 parents 055b966 + 099982b commit 7d8c57a

10 files changed

Lines changed: 106 additions & 8 deletions

File tree

‎dspy/adapters/types/tool.py‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import asyncio
22
import inspect
3+
from decimal import Decimal
34
from typing import TYPE_CHECKING, Any, Callable, Literal, Protocol, get_origin, get_type_hints
45

56
import json_repair
67
import pydantic
8+
import pydantic_core
79
from jsonschema import ValidationError, validate
810
from pydantic import BaseModel, TypeAdapter, create_model
911

@@ -111,7 +113,7 @@ def _parse_function(self, func: Callable, arg_desc: dict[str, str] | None = None
111113
else:
112114
args[k] = _resolve_json_schema_reference(TypeAdapter(v).json_schema())
113115
if default_values[k] is not inspect.Parameter.empty:
114-
args[k]["default"] = default_values[k]
116+
args[k]["default"] = _to_json_default(default_values[k])
115117
if arg_desc and k in arg_desc:
116118
args[k]["description"] = arg_desc[k]
117119

@@ -520,6 +522,17 @@ def _normalize_tool_call_dict(data: dict[str, Any]) -> dict[str, Any]:
520522
}
521523

522524

525+
def _to_json_default(value: Any) -> Any:
526+
"""Convert a parameter default to its JSON form, e.g. an Enum member to its value."""
527+
if isinstance(value, Decimal):
528+
# A JSON number fits both float and Decimal arguments; pydantic would give a string.
529+
return float(value)
530+
try:
531+
return pydantic_core.to_jsonable_python(value)
532+
except Exception:
533+
return value
534+
535+
523536
def _resolve_json_schema_reference(schema: dict) -> dict:
524537
"""Recursively resolve json model schema, expanding all references."""
525538

‎dspy/adapters/utils.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,10 @@ def parse_value(value, annotation):
240240
def get_annotation_name(annotation):
241241
origin = get_origin(annotation)
242242
args = get_args(annotation)
243+
if origin is types.UnionType:
244+
# Render PEP 604 unions (e.g. `str | None`) the same way as `typing.Union`/`Optional` on Python < 3.14,
245+
# matching Python 3.14+ where `get_origin(str | None)` already returns `typing.Union`.
246+
origin = Union
243247
if origin is None:
244248
if annotation is Reasoning:
245249
# Keep backward compatibility with the old behavior in `dspy.ChainOfThought`, where reasoning
Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
2-
"source": "https://files.pythonhosted.org/packages/70/9a/36e3ea2ad9d79df1d9aa978dde42323321b339e42c71a0d0b9c9a5179dc7/litellm-1.104.2-cp310-abi3-macosx_10_12_x86_64.whl",
3-
"version": "1.104.2",
4-
"wheel_sha256": "0d43c16d93e7c47cf0a4028eeaa21f95ba677d0828d32be4af5363abdbcf109d",
5-
"snapshot_sha256": "3cedaa2f5f2ae54424d9f8a10b89b7a7b12809922c97dd9c91609c2164b20fa4"
2+
"source": "https://files.pythonhosted.org/packages/32/15/443c2c8b2bab8eef188d8bc5d41412761d943d397608b48eaec6fe5b4c12/litellm-1.105.0-cp310-abi3-macosx_10_12_x86_64.whl",
3+
"version": "1.105.0",
4+
"wheel_sha256": "300e2b6a2eed4c54a40a7753521611d3aa60599c068e200d9f41ac78ff2ca319",
5+
"snapshot_sha256": "329113e5820834dc2a206500db9ec7861a17c74b86fe79601dec05478c06327e"
66
}
5.23 KB
Binary file not shown.

‎dspy/predict/react_v2.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@
2020
# expansion at the `Prediction(**final_outputs, history=..., termination_reason=...)` call sites.
2121
_RESERVED_PREDICTION_KEYS = frozenset({"history", "termination_reason"})
2222

23+
# Internal input field names ReActV2 injects into its inner Predict signature. A user
24+
# input with one of these names would be silently overwritten by `_make_react_signature`
25+
# and popped out of the caller's kwargs by `forward`/`aforward`, so it must be rejected.
26+
_RESERVED_INPUT_KEYS = frozenset({"history", "tools"})
27+
2328
if TYPE_CHECKING:
2429
from dspy.signatures.signature import Signature
2530

@@ -52,6 +57,14 @@ def __init__(self, signature: type[Signature], tools: list[Callable | Tool], max
5257
"returned Prediction. Rename these output fields on your signature."
5358
)
5459

60+
reserved_inputs = _RESERVED_INPUT_KEYS.intersection(self.signature.input_fields)
61+
if reserved_inputs:
62+
names = ", ".join(f"`{name}`" for name in sorted(reserved_inputs))
63+
raise ValueError(
64+
f"Input field name(s) {names} are reserved by ReActV2 for its internal "
65+
"conversation history and tool list. Rename these input fields on your signature."
66+
)
67+
5568
user_tools = [tool if isinstance(tool, Tool) else Tool(tool) for tool in tools]
5669
self.tools = {tool.name: tool for tool in user_tools}
5770
if "submit" in self.tools:

‎tests/adapters/test_adapter_utils.py‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import pytest
66
from pydantic import BaseModel
77

8-
from dspy.adapters.utils import parse_value
8+
from dspy.adapters.utils import get_annotation_name, parse_value
99

1010

1111
class Profile(BaseModel):
@@ -115,3 +115,18 @@ def test_parse_value_json_repair():
115115
malformed = "not json or literal"
116116
with pytest.raises(Exception):
117117
parse_value(malformed, dict)
118+
119+
120+
@pytest.mark.parametrize(
121+
"pep604_annotation,typing_annotation,expected",
122+
[
123+
(str | None, Optional[str], "Union[str, NoneType]"), # noqa: UP045
124+
(int | str, Union[int, str], "Union[int, str]"),
125+
(int | str | None, Union[int, str, None], "Union[int, str, NoneType]"),
126+
(list[str] | None, Optional[list[str]], "Union[list[str], NoneType]"), # noqa: UP045
127+
(dict[str, int | None], dict[str, Optional[int]], "dict[str, Union[int, NoneType]]"), # noqa: UP045
128+
],
129+
)
130+
def test_get_annotation_name_pep604_union_matches_typing_union(pep604_annotation, typing_annotation, expected):
131+
assert get_annotation_name(typing_annotation) == expected
132+
assert get_annotation_name(pep604_annotation) == expected

‎tests/adapters/test_chat_adapter.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3050,3 +3050,15 @@ class OptionalSyntaxSignature(dspy.Signature):
30503050
with dspy.context(lm=DummyLM(responses), adapter=dspy.ChatAdapter()):
30513051
with pytest.raises(AdapterParseError):
30523052
dspy.Predict(OptionalSyntaxSignature)(question="anything")
3053+
3054+
3055+
def test_chat_adapter_formats_pep604_union_like_typing_union():
3056+
class QA(dspy.Signature):
3057+
question: str = dspy.InputField()
3058+
answer: int | None = dspy.OutputField()
3059+
3060+
messages = dspy.ChatAdapter().format(QA, [], {"question": "How many?"})
3061+
3062+
assert "1. `answer` (Union[int, NoneType])" in messages[0]["content"]
3063+
assert "must be formatted as a valid Python Union[int, NoneType]" in messages[-1]["content"]
3064+
assert "UnionType" not in messages[0]["content"] + messages[-1]["content"]

‎tests/adapters/test_tool.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,42 @@ def test_tool_from_function():
146146
assert tool.args["y"]["default"] == "hello"
147147

148148

149+
def test_tool_defaults_are_json_serializable():
150+
import datetime
151+
import enum
152+
import json
153+
from decimal import Decimal
154+
155+
class Unit(enum.Enum):
156+
CELSIUS = "celsius"
157+
FAHRENHEIT = "fahrenheit"
158+
159+
def forecast(
160+
city: str,
161+
unit: Unit = Unit.CELSIUS,
162+
start: datetime.date = datetime.date(2026, 1, 2),
163+
price: Decimal = Decimal("1.5"),
164+
factor: float = Decimal("0.1"),
165+
) -> str:
166+
return city
167+
168+
tool = Tool(forecast)
169+
assert tool.args["unit"]["default"] == "celsius"
170+
assert tool.args["start"]["default"] == "2026-01-02"
171+
assert tool.args["price"]["default"] == 1.5
172+
assert tool.args["factor"]["default"] == 0.1
173+
assert tool(city="x", price=tool.args["price"]["default"], factor=tool.args["factor"]["default"]) == "x"
174+
json.dumps(tool.format_as_litellm_function_call())
175+
176+
177+
def test_tool_defaults_without_json_form_are_kept():
178+
def scale(data: bytes = b"\xff") -> str:
179+
return "ok"
180+
181+
tool = Tool(scale)
182+
assert tool.args["data"]["default"] == b"\xff"
183+
184+
149185
def test_format_as_litellm_function_call_excludes_defaulted_args_from_required():
150186
# `x` has no default and must be required; `y` has a default and must be optional.
151187
tool = Tool(dummy_function)

‎tests/adapters/test_xml_adapter.py‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import sys
21
from unittest import mock
32

43
import pydantic
@@ -454,7 +453,7 @@ class QA(dspy.Signature):
454453
assert messages[0]["role"] == "system"
455454
assert messages[1]["role"] == "user"
456455

457-
union_type_repr = "Union[str, NoneType]" if sys.version_info >= (3, 14) else "UnionType[str, NoneType]"
456+
union_type_repr = "Union[str, NoneType]"
458457

459458
expected_system = (
460459
"Your input fields are:\n"

‎tests/predict/test_react_v2.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,12 @@ def test_react_v2_rejects_reserved_output_field_names(reserved):
362362
dspy.ReActV2(f"question -> answer, {reserved}: str", tools=[])
363363

364364

365+
@pytest.mark.parametrize("reserved", ["history", "tools"])
366+
def test_react_v2_rejects_reserved_input_field_names(reserved):
367+
with pytest.raises(ValueError, match=reserved):
368+
dspy.ReActV2(f"{reserved}: str -> answer: str", tools=[])
369+
370+
365371
@pytest.mark.asyncio
366372
@pytest.mark.parametrize("use_async", [False, True])
367373
@pytest.mark.parametrize("break_reason", ["max_iters", "empty_tool_calls", "parse_error", "context_window_exceeded"])

0 commit comments

Comments
 (0)