Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
6af5b75
support for native reasoning in CoT for reasoning models
arnavsinghvi11 Sep 4, 2025
c699a1f
ruff and test
arnavsinghvi11 Sep 4, 2025
beb85de
merge main
chenmoneygithub Sep 16, 2025
4c5b633
merge main
chenmoneygithub Sep 17, 2025
5228863
Introduce dspy.Reasoning to handle ChainOfThought on reasoning models
chenmoneygithub Sep 18, 2025
3210914
remove unintended file
chenmoneygithub Sep 18, 2025
d5b0dfb
Merge branch 'main' into cot_reasoning
chenmoneygithub Sep 18, 2025
b2daf8f
fix
chenmoneygithub Sep 18, 2025
3cff43a
make reasoning string-like
chenmoneygithub Sep 18, 2025
3258da5
increment
chenmoneygithub Sep 19, 2025
8de0a65
go
chenmoneygithub Sep 19, 2025
ec2fbe4
polish the docstring
chenmoneygithub Sep 19, 2025
56973f0
automatically turn on reasoning for COT on reasoning model
chenmoneygithub Sep 19, 2025
c65b774
comments
chenmoneygithub Sep 22, 2025
8c1630c
fix tests
chenmoneygithub Sep 23, 2025
93991f5
merge main
chenmoneygithub Oct 1, 2025
67eda2c
merge main
chenmoneygithub Oct 23, 2025
b7b4dcf
fix
chenmoneygithub Oct 24, 2025
d810943
Merge branch 'main' into cot_reasoning
chenmoneygithub Oct 28, 2025
1e4ebe2
add dspy.Reasoning
chenmoneygithub Oct 28, 2025
6801afa
Merge branch 'main' into dspy-reasoning
chenmoneygithub Oct 28, 2025
417737a
comments
chenmoneygithub Oct 29, 2025
afba048
Merge branch 'main' into dspy-reasoning
chenmoneygithub Nov 7, 2025
8f1b0df
merge main
chenmoneygithub Nov 7, 2025
eafc979
merge main
chenmoneygithub Nov 13, 2025
d0499af
add comment for backward compatibility
chenmoneygithub Nov 13, 2025
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
2 changes: 1 addition & 1 deletion dspy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from dspy.evaluate import Evaluate # isort: skip
from dspy.clients import * # isort: skip
from dspy.adapters import Adapter, ChatAdapter, JSONAdapter, XMLAdapter, TwoStepAdapter, Image, Audio, File, History, Type, Tool, ToolCalls, Code # isort: skip
from dspy.adapters import Adapter, ChatAdapter, JSONAdapter, XMLAdapter, TwoStepAdapter, Image, Audio, File, History, Type, Tool, ToolCalls, Code, Reasoning # isort: skip
from dspy.utils.logging_utils import configure_dspy_loggers, disable_logging, enable_logging
from dspy.utils.asyncify import asyncify
from dspy.utils.syncify import syncify
Expand Down
3 changes: 2 additions & 1 deletion dspy/adapters/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from dspy.adapters.chat_adapter import ChatAdapter
from dspy.adapters.json_adapter import JSONAdapter
from dspy.adapters.two_step_adapter import TwoStepAdapter
from dspy.adapters.types import Audio, Code, File, History, Image, Tool, ToolCalls, Type
from dspy.adapters.types import Audio, Code, File, History, Image, Reasoning, Tool, ToolCalls, Type
from dspy.adapters.xml_adapter import XMLAdapter

__all__ = [
Expand All @@ -19,4 +19,5 @@
"TwoStepAdapter",
"Tool",
"ToolCalls",
"Reasoning",
]
18 changes: 11 additions & 7 deletions dspy/adapters/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from dspy.adapters.types import History, Type
from dspy.adapters.types.base_type import split_message_content_for_custom_types
from dspy.adapters.types.reasoning import Reasoning
from dspy.adapters.types.tool import Tool, ToolCalls
from dspy.experimental import Citations
from dspy.signatures.signature import Signature
Expand All @@ -16,7 +17,7 @@
if TYPE_CHECKING:
from dspy.clients.lm import LM

_DEFAULT_NATIVE_RESPONSE_TYPES = [Citations]
_DEFAULT_NATIVE_RESPONSE_TYPES = [Citations, Reasoning]


class Adapter:
Expand Down Expand Up @@ -99,14 +100,14 @@ def _call_preprocess(

return signature_for_native_function_calling

# Handle custom types that use native response
# Handle custom types that use native LM features, e.g., reasoning, citations, etc.
for name, field in signature.output_fields.items():
if (
isinstance(field.annotation, type)
and issubclass(field.annotation, Type)
and field.annotation in self.native_response_types
):
signature = signature.delete(name)
signature = field.annotation.adapt_to_native_lm_feature(signature, name, lm, lm_kwargs)

return signature

Expand All @@ -116,6 +117,7 @@ def _call_postprocess(
original_signature: type[Signature],
outputs: list[dict[str, Any] | str],
lm: "LM",
lm_kwargs: dict[str, Any],
) -> list[dict[str, Any]]:
values = []

Expand Down Expand Up @@ -152,14 +154,16 @@ def _call_postprocess(
]
value[tool_call_output_field_name] = ToolCalls.from_dict_list(tool_calls)

# Parse custom types that does not rely on the adapter parsing
# Parse custom types that does not rely on the `Adapter.parse()` method
for name, field in original_signature.output_fields.items():
if (
isinstance(field.annotation, type)
and issubclass(field.annotation, Type)
and field.annotation in self.native_response_types
):
value[name] = field.annotation.parse_lm_response(output)
parsed_value = field.annotation.parse_lm_response(output)
if parsed_value is not None:
value[name] = parsed_value

if output_logprobs:
value["logprobs"] = output_logprobs
Expand Down Expand Up @@ -196,7 +200,7 @@ def __call__(
inputs = self.format(processed_signature, demos, inputs)

outputs = lm(messages=inputs, **lm_kwargs)
return self._call_postprocess(processed_signature, signature, outputs, lm)
return self._call_postprocess(processed_signature, signature, outputs, lm, lm_kwargs)

async def acall(
self,
Expand All @@ -210,7 +214,7 @@ async def acall(
inputs = self.format(processed_signature, demos, inputs)

outputs = await lm.acall(messages=inputs, **lm_kwargs)
return self._call_postprocess(processed_signature, signature, outputs, lm)
return self._call_postprocess(processed_signature, signature, outputs, lm, lm_kwargs)

def format(
self,
Expand Down
3 changes: 2 additions & 1 deletion dspy/adapters/types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from dspy.adapters.types.file import File
from dspy.adapters.types.history import History
from dspy.adapters.types.image import Image
from dspy.adapters.types.reasoning import Reasoning
from dspy.adapters.types.tool import Tool, ToolCalls

__all__ = ["History", "Image", "Audio", "File", "Type", "Tool", "ToolCalls", "Code"]
__all__ = ["History", "Image", "Audio", "File", "Type", "Tool", "ToolCalls", "Code", "Reasoning"]
31 changes: 30 additions & 1 deletion dspy/adapters/types/base_type.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import json
import re
from typing import Any, Optional, get_args, get_origin
from typing import TYPE_CHECKING, Any, Optional, get_args, get_origin

import json_repair
import pydantic
from litellm import ModelResponseStream

if TYPE_CHECKING:
from dspy.clients.lm import LM
from dspy.signatures.signature import Signature

CUSTOM_TYPE_START_IDENTIFIER = "<<CUSTOM-TYPE-START-IDENTIFIER>>"
CUSTOM_TYPE_END_IDENTIFIER = "<<CUSTOM-TYPE-END-IDENTIFIER>>"

Expand Down Expand Up @@ -70,6 +74,31 @@ def serialize_model(self):
)
return formatted

@classmethod
def adapt_to_native_lm_feature(
cls,
signature: type["Signature"],
field_name: str,
lm: "LM",
lm_kwargs: dict[str, Any],
) -> type["Signature"]:
"""Adapt the custom type to the native LM feature if possible.

When the LM and configuration supports the related native LM feature, e.g., native tool calling, native
reasoning, etc., we adapt the signature and `lm_kwargs` to enable the native LM feature.

Args:
signature: The DSPy signature for the LM call.
field_name: The name of the field in the signature to adapt to the native LM feature.
lm: The LM instance.
lm_kwargs: The keyword arguments for the LM call, subject to in-place updates if adaptation if required.

Returns:
The adapted signature. If the custom type is not natively supported by the LM, return the original
signature.
"""
return signature

@classmethod
def is_streamable(cls) -> bool:
"""Whether the custom type is streamable."""
Expand Down
6 changes: 6 additions & 0 deletions dspy/adapters/types/citation.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,12 @@ def __getitem__(self, index):
"""Allow indexing into citations."""
return self.citations[index]

@classmethod
def adapt_to_native_lm_feature(cls, signature, field_name, lm, lm_kwargs) -> bool:
if lm.model.startswith("anthropic/"):
return signature.delete(field_name)
return signature

@classmethod
def is_streamable(cls) -> bool:
"""Whether the Citations type is streamable."""
Expand Down
118 changes: 118 additions & 0 deletions dspy/adapters/types/reasoning.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
from typing import TYPE_CHECKING, Any, Optional

import litellm
import pydantic

from dspy.adapters.types.base_type import Type

if TYPE_CHECKING:
from dspy.clients.lm import LM
from dspy.signatures.signature import Signature


class Reasoning(Type):
"""Reasoning type in DSPy.

This type is useful when you want the DSPy output to include the reasoning of the LM. We build this type so that
DSPy can support the reasoning model and non-reasoning model with the same code.

This is a str-like type, you can convert a string directly to a Reasoning object, and from DSPy adapters'
perspective, `Reasoning` is treated as a string.
"""

content: str

def format(self):
return f"{self.content}"

@pydantic.model_validator(mode="before")
@classmethod
def validate_input(cls, data: Any):
if isinstance(data, cls):
return data

if isinstance(data, str):
return {"content": data}

if isinstance(data, dict):
if "content" not in data:
raise ValueError("`content` field is required for `dspy.Reasoning`")
if not isinstance(data["content"], str):
raise ValueError(f"`content` field must be a string, but received type: {type(data['content'])}")
return {"content": data["content"]}

raise ValueError(f"Received invalid value for `dspy.Reasoning`: {data}")

@classmethod
def adapt_to_native_lm_feature(
cls,
signature: type["Signature"],
field_name: str,
lm: "LM",
lm_kwargs: dict[str, Any],
) -> type["Signature"]:
if "reasoning_effort" in lm_kwargs:
# `lm_kwargs` overrides `lm.kwargs`.
reasoning_effort = lm_kwargs["reasoning_effort"]
elif "reasoning_effort" in lm.kwargs:
reasoning_effort = lm.kwargs["reasoning_effort"]
else:
# Turn on the native reasoning explicitly if Reasoning field is present in the signature and no explicit
# reasoning effort is set in `lm_kwargs` or `lm.kwargs`.
reasoning_effort = "low"

if reasoning_effort is None or not litellm.supports_reasoning(lm.model):
# If users explicitly set `reasoning_effort` to None or the LM doesn't support reasoning, we don't enable
# native reasoning.
return signature

if "gpt-5" in lm.model and lm.model_type == "chat":
# There is a caveat of Litellm as 1.79.0 that when using the chat completion API on GPT-5 family models,
# the reasoning content is not available in the response. As a workaround, we don't enable the native
# reasoning feature for GPT-5 family models when using the chat completion API.
# Litellm issue: https://github.com/BerriAI/litellm/issues/14748
return signature

lm_kwargs["reasoning_effort"] = reasoning_effort
# Delete the reasoning field from the signature to use the native reasoning feature.
return signature.delete(field_name)

@classmethod
def parse_lm_response(cls, response: str | dict[str, Any]) -> Optional["Reasoning"]:
"""Parse the LM response into a Reasoning object."""
if "reasoning_content" in response:
return Reasoning(content=response["reasoning_content"])
return None

@classmethod
def parse_stream_chunk(cls, chunk) -> str | None:
"""
Parse a stream chunk into reasoning content if available.

Args:
chunk: A stream chunk from the LM.

Returns:
The reasoning content (str) if available, None otherwise.
"""
try:
if choices := getattr(chunk, "choices", None):
return getattr(choices[0].delta, "reasoning_content", None)
except Exception:
return None

@classmethod
def is_streamable(cls) -> bool:
return True

def __repr__(self) -> str:
return f"{self.content!r}"

def __str__(self) -> str:
return self.content

def __eq__(self, other: object) -> bool:
if isinstance(other, Reasoning):
return self.content == other.content
if isinstance(other, str):
return self.content == other
7 changes: 6 additions & 1 deletion dspy/adapters/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from pydantic.fields import FieldInfo

from dspy.adapters.types.base_type import Type as DspyType
from dspy.adapters.types.reasoning import Reasoning
from dspy.signatures.utils import get_dspy_field_type


Expand Down Expand Up @@ -84,7 +85,7 @@ def move_type_to_front(d):
def translate_field_type(field_name, field_info):
field_type = field_info.annotation

if get_dspy_field_type(field_info) == "input" or field_type is str:
if get_dspy_field_type(field_info) == "input" or field_type is str or field_type is Reasoning:
Copy link
Collaborator

Choose a reason for hiding this comment

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

ditto

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

same as above, let me know your thought!

desc = ""
elif field_type is bool:
desc = "must be True or False"
Expand Down Expand Up @@ -190,6 +191,10 @@ def get_annotation_name(annotation):
origin = get_origin(annotation)
args = get_args(annotation)
if origin is None:
if annotation is Reasoning:
Copy link
Collaborator

@TomeHirata TomeHirata Oct 29, 2025

Choose a reason for hiding this comment

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

Is there any way to implement the conversion more generically? Ideally this information should reside in Reasoning.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

that's a good question.

I did think about the same thing, but changing the __name__ in dspy.Reasoning could lead to confusion, because essentially it's just a type, but from the perspective of DSPy Adapter, it is treated as string. So I kept the logic inside adapter utils.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Got it, we need this conversion so that LLM won't return reasoning: {content: "xxx"}?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yes exactly

Copy link
Collaborator

Choose a reason for hiding this comment

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

Understood, is reasoning: {content: "xxx"} that bad? Iirc, ToolCalls is handled in this way (something like tool_calls: [{"name": "tool_a"}]), so having a consistent behavior might not be a bad idea.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This is for backward compatibility of ChainOfThought. Since ChainOfThought is the module that gets the most usage by DSPy users except from dspy.Predict, I would keep the behavior unchanged as much as possible.

This PR only includes dspy.Reasoning, but we will have a followup to migrate ChainOfThought ot dspy.Reasnong. The purpose of dspy.Reasoning is providing a way to turn on the native reasoning, while not changing other behavior of ChainOfThought. Let me know if this makes sense to you!

Copy link
Collaborator

@TomeHirata TomeHirata Nov 12, 2025

Choose a reason for hiding this comment

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

Just to confirm, you meant the consistency of the LLM response for ChainOfThought instead of the return signature of ChainOfThought.__call__ by "backward compatibility"? For the latter, we can just return dspy.Reasning.content as reasoning field of ChainOfThought so that the return type is consistent to the existing behavior. But I agree if it's the former.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yes exactly, for example the result of dspy.inspect_history() will be changed without this conversion.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Understood, can you add the comment? Also isn't the behavior changed anyway when native reasoning field is used?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Added the comment!

Also isn't the behavior changed anyway when native reasoning field is used?

So for reasoning model, the behavior is changed, which is intentional. But for non-reasoning model, we want to keep it exactly the same behavior as before.

# Keep backward compatibility with the old behavior in `dspy.ChainOfThought`, where reasoning
# field type is treated as a string.
return "str"
if hasattr(annotation, "__name__"):
return annotation.__name__
else:
Expand Down
5 changes: 4 additions & 1 deletion dspy/clients/base_lm.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,10 @@ def _process_completion(self, response, merged_kwargs):
for c in response.choices:
output = {}
output["text"] = c.message.content if hasattr(c, "message") else c["text"]

if hasattr(c, "message") and hasattr(c.message, "reasoning_content") and c.message.reasoning_content:
output["reasoning_content"] = c.message.reasoning_content

if merged_kwargs.get("logprobs"):
output["logprobs"] = c.logprobs if hasattr(c, "logprobs") else c["logprobs"]
if hasattr(c, "message") and getattr(c.message, "tool_calls", None):
Expand All @@ -219,7 +223,6 @@ def _process_completion(self, response, merged_kwargs):
if all(len(output) == 1 for output in outputs):
# Return a list if every output only has "text" key
outputs = [output["text"] for output in outputs]

return outputs

def _extract_citations_from_response(self, choice):
Expand Down
4 changes: 4 additions & 0 deletions dspy/clients/lm.py
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,10 @@ def _convert_chat_request_to_responses_request(request: dict[str, Any]):
for item in c:
content_blocks.append(_convert_content_item_to_responses_format(item))
request["input"] = [{"role": msg.get("role", "user"), "content": content_blocks}]
# Convert `reasoning_effort` to reasoning format supported by the Responses API
if "reasoning_effort" in request:
effort = request.pop("reasoning_effort")
request["reasoning"] = {"effort": effort, "summary": "auto"}

# Convert `response_format` to `text.format` for Responses API
if "response_format" in request:
Expand Down
15 changes: 8 additions & 7 deletions dspy/streaming/streaming_listener.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,13 +134,6 @@ def receive(self, chunk: ModelResponseStream):
else:
return

try:
chunk_message = chunk.choices[0].delta.content
if chunk_message is None:
return
except Exception:
return

# Handle custom streamable types
if self._output_type and issubclass(self._output_type, Type) and self._output_type.is_streamable():
if parsed_chunk := self._output_type.parse_stream_chunk(chunk):
Expand All @@ -151,6 +144,14 @@ def receive(self, chunk: ModelResponseStream):
is_last_chunk=self.stream_end,
)

# For non-custom streamable types, the streaming chunks come from the content field of the ModelResponseStream.
try:
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: maybe add comment why this logic should come after native response handling?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

good call, done!

chunk_message = chunk.choices[0].delta.content
if chunk_message is None:
return
except Exception:
return

if chunk_message and start_identifier in chunk_message and not isinstance(settings.adapter, JSONAdapter):
# If the cache is hit, the chunk_message could be the full response. When it happens we can
# directly end the stream listening. In some models like gemini, each stream chunk can be multiple
Expand Down
Loading