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
132 changes: 123 additions & 9 deletions dspy/adapters/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
from typing import Any, get_origin

from dspy.adapters.types import Audio, File, History, Image, 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.clients.base_lm import BaseLM
from dspy.clients.openai_format import provider_tool_call_to_part, to_openai_chat_request
from dspy.clients.openai_format import message_to_openai_chat, provider_tool_call_to_part, to_openai_chat_request
from dspy.core.types import (
LMAudioPart,
LMBinaryPart,
Expand All @@ -26,7 +27,14 @@

logger = logging.getLogger(__name__)

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


def _native_tool_call_instruction(field_name: str) -> str:
return (
"When tool use is needed, call the available tools through the native tool-call interface. "
f"Do not emit `{field_name}` as a text or JSON output field."
)


class Adapter:
Expand All @@ -51,6 +59,7 @@ def __init__(
callbacks: list[BaseCallback] | None = None,
use_native_function_calling: bool = False,
native_response_types: list[type[Type]] | None = None,
allow_parallel_tool_calls: bool | None = None,
):
"""
Args:
Expand All @@ -61,11 +70,16 @@ def __init__(
or `list[dspy.Tool]` types. Defaults to False.
native_response_types: List of output field types that should be handled by native LM features rather than
adapter parsing. For example, `dspy.Citations` can be populated directly by citation APIs
(e.g., Anthropic's citation feature). Defaults to `[Citations]`.
(e.g., Anthropic's citation feature). Defaults to `[Citations, Reasoning, ToolCalls]`.
allow_parallel_tool_calls: Whether to request provider-side parallel tool calls when native function calling
is active. If `None`, leaves the provider default unchanged. Defaults to None.
"""
self.callbacks = callbacks or []
self.use_native_function_calling = use_native_function_calling
self.native_response_types = native_response_types or _DEFAULT_NATIVE_RESPONSE_TYPES
self.native_response_types = list(
_DEFAULT_NATIVE_RESPONSE_TYPES if native_response_types is None else native_response_types
)
self.allow_parallel_tool_calls = allow_parallel_tool_calls

def __init_subclass__(cls, **kwargs) -> None:
super().__init_subclass__(**kwargs)
Expand All @@ -88,6 +102,7 @@ def plan_fields(
messages: list[LMMessage] = []
user_parts: list[LMPart] = []
tools: list[LMToolSpec] = []
native_feature_instructions: list[str] = []
history_has_open_episode = False

for name, field in list(prompt_signature.input_fields.items()):
Expand Down Expand Up @@ -129,12 +144,18 @@ def plan_fields(
"input field with type `list[dspy.Tool]`."
)

if tool_call_output_field_name and lm.supports_function_calling:
if tool_call_output_field_name and getattr(lm, "supports_function_calling", False):
tool_values = inputs[tool_call_input_field_name]
tool_values = tool_values if isinstance(tool_values, list) else [tool_values]
tools.extend(self._tool_to_lm_tool_spec(tool) for tool in tool_values)
prompt_signature = prompt_signature.delete(tool_call_output_field_name).delete(tool_call_input_field_name)
inputs.pop(tool_call_input_field_name, None)
if self.allow_parallel_tool_calls is not None:
lm_kwargs["parallel_tool_calls"] = self.allow_parallel_tool_calls
native_feature_instructions.append(_native_tool_call_instruction(tool_call_output_field_name))

if "tool_choice" in lm_kwargs and not tools:
lm_kwargs.pop("tool_choice")

if history_has_open_episode:
for input_name in list(prompt_signature.input_fields):
Expand All @@ -149,6 +170,13 @@ def plan_fields(
if getattr(lm, "model", "").startswith("anthropic/"):
prompt_signature = prompt_signature.delete(name)

if native_feature_instructions:
prompt_signature = self._with_native_feature_instructions(
signature,
prompt_signature,
native_feature_instructions,
)

return {
"original_signature": signature,
"prompt_signature": prompt_signature,
Expand All @@ -159,6 +187,20 @@ def plan_fields(
"tools": tools,
}

def _with_native_feature_instructions(
self,
original_signature: type[Signature],
prompt_signature: type[Signature],
native_feature_instructions: list[str],
) -> type[Signature]:
original_default_instructions = Signature(original_signature.fields).instructions
prompt_default_instructions = Signature(prompt_signature.fields).instructions
if original_signature.instructions == original_default_instructions:
instructions = prompt_default_instructions
else:
instructions = prompt_signature.instructions
return prompt_signature.with_instructions("\n".join([instructions, *native_feature_instructions]))

def render_request(
self,
plan: dict[str, Any],
Expand All @@ -167,7 +209,7 @@ def render_request(
inputs: dict[str, Any],
) -> LMRequest:
"""Render the prompt-facing signature into a normalized LM request."""
messages = self.format(plan["prompt_signature"], demos, plan["inputs"])
messages = self.render_messages(plan["prompt_signature"], demos, plan["inputs"])
messages = self._apply_planned_messages(messages, plan)
request_kwargs = dict(plan["lm_kwargs"])
tools = list(plan.get("tools", []))
Expand All @@ -190,6 +232,7 @@ def _legacy_call_kwargs(self, request: LMRequest) -> dict[str, Any]:
# `dspy.LM` converts those messages to text-completion or Responses requests internally.
data = to_openai_chat_request(request)
data.pop("model", None)
data["messages"] = split_message_content_for_custom_types(data["messages"])
if request.config.cache is not None:
if request.config.cache.enabled is not None:
data["cache"] = request.config.cache.enabled
Expand Down Expand Up @@ -229,6 +272,11 @@ def _uses_native_tool_calls(self, signature: type[Signature], lm: BaseLM) -> boo
and self._get_tool_call_output_field_name(signature) is not None
)

def force_tool_call_config(self, tool_name: str) -> dict[str, Any]:
if not self.use_native_function_calling:
return {}
return {"tool_choice": {"mode": "required", "allowed": [tool_name]}}

def _image_to_lm_part(self, image: Image) -> LMImagePart:
source = image.url
if source.startswith("data:") and "," in source:
Expand Down Expand Up @@ -277,11 +325,48 @@ def _plan_native_reasoning(
lm_kwargs["reasoning_effort"] = reasoning_effort
return signature.delete(field_name)

def _call_preprocess(
self,
lm: BaseLM,
lm_kwargs: dict[str, Any],
signature: type[Signature],
inputs: dict[str, Any],
) -> type[Signature]:
plan = self.plan_fields(lm, lm_kwargs, signature, inputs)
request = self.render_request(plan, lm, [], inputs)
legacy_kwargs = self._legacy_call_kwargs(request)
legacy_kwargs.pop("messages", None)
lm_kwargs.clear()
lm_kwargs.update(legacy_kwargs)
return plan["prompt_signature"]

def _call_postprocess(
self,
prompt_signature: type[Signature],
original_signature: type[Signature],
outputs: list[dict[str, Any] | str],
lm: BaseLM | None,
lm_kwargs: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
request = LMRequest.from_call(
model=getattr(lm, "model", ""),
messages=[LMMessage(role="user", parts=[LMTextPart(text="")])],
**(lm_kwargs or {}),
)
response = self.normalize_legacy_outputs(outputs, request)
plan = {
"original_signature": original_signature,
"prompt_signature": prompt_signature,
}
return self.parse_response(plan, response, lm)

def normalize_legacy_outputs(self, outputs: list[dict[str, Any] | str], request: LMRequest) -> LMResponse:
"""Convert legacy adapter outputs into a normalized `LMResponse` immediately after the LM call."""
return LMResponse(model=request.model, outputs=[self._legacy_output_to_lm_output(output) for output in outputs])

def _legacy_output_to_lm_output(self, output: dict[str, Any] | str) -> LMOutput:
def _legacy_output_to_lm_output(self, output: dict[str, Any] | str | None) -> LMOutput:
if output is None:
return LMOutput(parts=[])
if isinstance(output, str):
return LMOutput(parts=[LMTextPart(text=output)])

Expand Down Expand Up @@ -389,11 +474,13 @@ async def acall(
response = await self.acall_lm(lm, request)
return self.parse_response(plan, response, lm)

def format(
def render_messages(
self,
signature: type[Signature],
demos: list[dict[str, Any]],
inputs: dict[str, Any],
*,
use_native_tool_calls: bool = False,
) -> list[LMMessage]:
"""Format the input messages for the LM call.

Expand Down Expand Up @@ -446,7 +533,15 @@ def format(

# In order to format the conversation history, we need to remove the history field from the signature.
signature_without_history = signature.delete(history_field_name)
conversation_history = history_obj.to_lm_messages(self, signature_without_history) if history_obj else []
conversation_history = (
history_obj.to_lm_messages(
self,
signature_without_history,
use_native_tool_calls=use_native_tool_calls,
)
if history_obj is not None
else []
)
inputs_copy.pop(history_field_name, None)
if has_open_episode:
for input_name in list(signature_without_history.input_fields):
Expand All @@ -469,6 +564,25 @@ def format(

return [message if isinstance(message, LMMessage) else LMMessage(**message) for message in messages]

def format(
self,
signature: type[Signature],
demos: list[dict[str, Any]],
inputs: dict[str, Any],
*,
use_native_tool_calls: bool = False,
) -> list[dict[str, Any]]:
messages = [
message_to_openai_chat(message)
for message in self.render_messages(
signature,
demos,
inputs,
use_native_tool_calls=use_native_tool_calls,
)
]
return split_message_content_for_custom_types(messages)

def format_system_message(self, signature: type[Signature]) -> str:
"""Format the system message for the LM call.

Expand Down
4 changes: 3 additions & 1 deletion dspy/adapters/chat_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ def __init__(
callbacks: list[BaseCallback] | None = None,
use_native_function_calling: bool = False,
native_response_types: list[type[type]] | None = None,
allow_parallel_tool_calls: bool | None = None,
use_json_adapter_fallback: bool = True,
):
"""
Expand All @@ -57,6 +58,7 @@ def __init__(
callbacks=callbacks,
use_native_function_calling=use_native_function_calling,
native_response_types=native_response_types,
allow_parallel_tool_calls=allow_parallel_tool_calls,
)
self.use_json_adapter_fallback = use_json_adapter_fallback

Expand Down Expand Up @@ -282,7 +284,7 @@ def format_finetune_data(
from dspy.clients.openai_format import message_to_openai_chat
from dspy.core.types import LMMessage, LMTextPart

system_user_messages = self.format(signature=signature, demos=demos, inputs=inputs)
system_user_messages = self.render_messages(signature=signature, demos=demos, inputs=inputs)
assistant_message_content = self.format_assistant_message_content(signature=signature, outputs=outputs)
assistant_message = LMMessage(role="assistant", parts=[LMTextPart(text=assistant_message_content)])
messages = [message_to_openai_chat(message) for message in [*system_user_messages, assistant_message]]
Expand Down
15 changes: 13 additions & 2 deletions dspy/adapters/json_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,20 @@ def _has_open_ended_mapping(signature: SignatureMeta) -> bool:


class JSONAdapter(ChatAdapter):
def __init__(self, callbacks: list[BaseCallback] | None = None, use_native_function_calling: bool = True):
def __init__(
self,
callbacks: list[BaseCallback] | None = None,
use_native_function_calling: bool = True,
native_response_types: list[type[type]] | None = None,
allow_parallel_tool_calls: bool | None = None,
):
# JSONAdapter uses native function calling by default.
super().__init__(callbacks=callbacks, use_native_function_calling=use_native_function_calling)
super().__init__(
callbacks=callbacks,
use_native_function_calling=use_native_function_calling,
native_response_types=native_response_types,
allow_parallel_tool_calls=allow_parallel_tool_calls,
)

def _json_adapter_call_common(self, lm, lm_kwargs, signature, demos, inputs, call_fn):
"""Common call logic to be used for both sync and async calls."""
Expand Down
8 changes: 4 additions & 4 deletions tests/adapters/test_history_formatting.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ def test_chat_adapter_formats_open_history_without_duplicate_input():
{"question": "What is 1+2?", "history": _history_with_open_tool_turn(), "tools": [dspy.Tool(add)]},
)

texts = [message.text or "" for message in messages]
texts = [message["content"] or "" for message in messages]

assert [message.role for message in messages] == ["system", "user", "assistant", "user", "user"]
assert [message["role"] for message in messages] == ["system", "user", "assistant", "user", "user"]
assert sum("What is 1+2?" in text for text in texts[1:]) == 1
assert "[[ ## next_thought ## ]]\nI should add." in texts[2]
assert "[[ ## tool_calls ## ]]" in texts[2]
Expand All @@ -54,10 +54,10 @@ def test_json_adapter_formats_open_history_without_duplicate_input():
{"question": "What is 1+2?", "history": _history_with_open_tool_turn(), "tools": [dspy.Tool(add)]},
)

texts = [message.text or "" for message in messages]
texts = [message["content"] or "" for message in messages]
assistant = json.loads(texts[2])

assert [message.role for message in messages] == ["system", "user", "assistant", "user", "user"]
assert [message["role"] for message in messages] == ["system", "user", "assistant", "user", "user"]
assert sum("What is 1+2?" in text for text in texts[1:]) == 1
assert assistant["next_thought"] == "I should add."
assert assistant["tool_calls"]["tool_calls"][0]["function"]["name"] == "add"
Expand Down
Loading