From 5042be0582a3d9291b0827faab117e755416020b Mon Sep 17 00:00:00 2001 From: isaacbmiller Date: Tue, 12 May 2026 15:17:45 -0400 Subject: [PATCH 01/14] refactor(tool-call): normalize wire shapes at LiteLLM boundary Adds `to_tool_call(item)` in `dspy/adapters/types/tool.py` as the single boundary that converts OpenAI Chat Completions and Responses API shapes (pydantic or dict) into a canonical `ToolCalls.ToolCall` with optional `id` field. `BaseLM` normalizes once at both extraction points so no downstream adapter, postprocess step, or history renderer ever sees a raw wire shape; consumers in `Adapter`, `TwoStepAdapter`, and `inspect_history` collapse to one-liners. Falls back to attribute access when `model_dump()` raises `TypeError` from the long-standing pydantic v2 MockValSer / SchemaSerializer bug (see pydantic/pydantic#7713 and BerriAI/litellm#9345). The fallback is contained to `to_tool_call` only. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- dspy/adapters/base.py | 11 +- dspy/adapters/two_step_adapter.py | 11 +- dspy/adapters/types/tool.py | 78 ++++++--- dspy/clients/base_lm.py | 6 +- dspy/utils/inspect_history.py | 2 +- tests/adapters/test_chat_adapter.py | 25 ++- tests/adapters/test_json_adapter.py | 8 +- .../adapters/test_tool_call_normalization.py | 162 ++++++++++++++++++ 8 files changed, 254 insertions(+), 49 deletions(-) create mode 100644 tests/adapters/test_tool_call_normalization.py diff --git a/dspy/adapters/base.py b/dspy/adapters/base.py index 7520856182..d9527559a4 100644 --- a/dspy/adapters/base.py +++ b/dspy/adapters/base.py @@ -1,8 +1,6 @@ import logging from typing import Any, get_origin -import json_repair - 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 @@ -148,14 +146,7 @@ def _call_postprocess( ) if tool_calls and tool_call_output_field_name: - tool_calls = [ - { - "name": v["function"]["name"], - "args": json_repair.loads(v["function"]["arguments"]), - } - for v in tool_calls - ] - value[tool_call_output_field_name] = ToolCalls.from_dict_list(tool_calls) + value[tool_call_output_field_name] = ToolCalls(tool_calls=list(tool_calls)) # Parse custom types that does not rely on the `Adapter.parse()` method for name, field in original_signature.output_fields.items(): diff --git a/dspy/adapters/two_step_adapter.py b/dspy/adapters/two_step_adapter.py index 0e427dea25..f2b9408e4c 100644 --- a/dspy/adapters/two_step_adapter.py +++ b/dspy/adapters/two_step_adapter.py @@ -1,7 +1,5 @@ from typing import Any -import json_repair - from dspy.adapters.base import Adapter from dspy.adapters.chat_adapter import ChatAdapter from dspy.adapters.types import ToolCalls @@ -145,14 +143,7 @@ async def acall( raise ValueError(f"Failed to parse response from the original completion: {output}") from e if tool_calls and tool_call_output_field_name: - tool_calls = [ - { - "name": v["function"]["name"], - "args": json_repair.loads(v["function"]["arguments"]), - } - for v in tool_calls - ] - value[tool_call_output_field_name] = ToolCalls.from_dict_list(tool_calls) + value[tool_call_output_field_name] = ToolCalls(tool_calls=list(tool_calls)) if output_logprobs is not None: value["logprobs"] = output_logprobs diff --git a/dspy/adapters/types/tool.py b/dspy/adapters/types/tool.py index e6deb9b7c2..2f57f6341b 100644 --- a/dspy/adapters/types/tool.py +++ b/dspy/adapters/types/tool.py @@ -2,6 +2,7 @@ import inspect from typing import TYPE_CHECKING, Any, Callable, get_origin, get_type_hints +import json_repair import pydantic from jsonschema import ValidationError, validate from pydantic import BaseModel, TypeAdapter, create_model @@ -259,10 +260,58 @@ def __str__(self): return f"{self.name}{desc} {arg_desc}" +def to_tool_call(item: Any) -> "ToolCalls.ToolCall": + """Normalize a LiteLLM tool-call into a canonical ``ToolCall``. + + Single boundary for wire-shape coercion. Falls back to attribute + access when ``model_dump()`` raises ``TypeError`` because of the + MockValSer/SchemaSerializer bug (pydantic#7713, litellm#9345). + """ + if not isinstance(item, dict) and hasattr(item, "model_dump"): + try: + item = item.model_dump() + except TypeError: + fn = getattr(item, "function", None) + if fn is not None: + return ToolCalls.ToolCall( + name=fn.name, args=_parse_args(fn.arguments), id=getattr(item, "id", None) + ) + if getattr(item, "name", None) is None: + raise + return ToolCalls.ToolCall( + name=item.name, + args=_parse_args(getattr(item, "arguments", None)), + id=getattr(item, "call_id", None) or getattr(item, "id", None), + ) + + if not isinstance(item, dict): + raise TypeError(f"Cannot normalize tool call from {type(item).__name__}: {item!r}") + + if item.get("type") == "function" and isinstance(item.get("function"), dict): + fn = item["function"] + return ToolCalls.ToolCall(name=fn["name"], args=_parse_args(fn.get("arguments")), id=item.get("id")) + + if item.get("type") == "function_call" and item.get("name"): + return ToolCalls.ToolCall( + name=item["name"], + args=_parse_args(item.get("arguments")), + id=item.get("call_id") or item.get("id"), + ) + + raise ValueError(f"Unknown tool-call shape: {item!r}") + + +def _parse_args(args: Any) -> dict[str, Any]: + if args is None or args == "": + return {} + return json_repair.loads(args) if isinstance(args, str) else args + + class ToolCalls(Type): class ToolCall(Type): name: str args: dict[str, Any] + id: str | None = None def format(self): return { @@ -359,30 +408,19 @@ def format(self) -> list[dict[str, Any]]: @pydantic.model_validator(mode="before") @classmethod def validate_input(cls, data: Any): + def coerce(items): + return [it if isinstance(it, cls.ToolCall) else cls.ToolCall(**it) for it in items] + if isinstance(data, cls): return data - - # Handle case where data is a list of dicts with "name" and "args" keys - if isinstance(data, list) and all( - isinstance(item, dict) and "name" in item and "args" in item for item in data - ): - return {"tool_calls": [cls.ToolCall(**item) for item in data]} - # Handle case where data is a dict - elif isinstance(data, dict): + if isinstance(data, list): + return {"tool_calls": coerce(data)} + if isinstance(data, dict): if "tool_calls" in data: - # Handle case where data is a dict with "tool_calls" key - tool_calls_data = data["tool_calls"] - if isinstance(tool_calls_data, list): - return { - "tool_calls": [ - cls.ToolCall(**item) if isinstance(item, dict) else item for item in tool_calls_data - ] - } - elif "name" in data and "args" in data: - # Handle case where data is a dict with "name" and "args" keys + return {"tool_calls": coerce(data["tool_calls"])} + if "name" in data and "args" in data: return {"tool_calls": [cls.ToolCall(**data)]} - - raise ValueError(f"Received invalid value for `dspy.ToolCalls`: {data}") + raise ValueError(f"Invalid value for `dspy.ToolCalls`: {data!r}") def _resolve_json_schema_reference(schema: dict) -> dict: diff --git a/dspy/clients/base_lm.py b/dspy/clients/base_lm.py index 760a2679b5..52b7995758 100644 --- a/dspy/clients/base_lm.py +++ b/dspy/clients/base_lm.py @@ -267,7 +267,8 @@ def _process_completion(self, response, merged_kwargs): 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): - output["tool_calls"] = c.message.tool_calls + from dspy.adapters.types.tool import to_tool_call # avoid circular import + output["tool_calls"] = [to_tool_call(tc) for tc in c.message.tool_calls] # Extract citations from LiteLLM response if available citations = self._extract_citations_from_response(c) @@ -319,7 +320,8 @@ def _process_response(self, response): for content_item in output_item.content: text_outputs.append(content_item.text) elif output_item_type == "function_call": - tool_calls.append(output_item.model_dump()) + from dspy.adapters.types.tool import to_tool_call # avoid circular import + tool_calls.append(to_tool_call(output_item)) elif output_item_type == "reasoning": if getattr(output_item, "content", None) and len(output_item.content) > 0: for content_item in output_item.content: diff --git a/dspy/utils/inspect_history.py b/dspy/utils/inspect_history.py index 46aebad1cc..e2522b1455 100644 --- a/dspy/utils/inspect_history.py +++ b/dspy/utils/inspect_history.py @@ -85,7 +85,7 @@ def pretty_print_history(history: list[dict[str, Any]], n: int = 1, file: TextIO if outputs[0].get("tool_calls"): print(_red("Tool calls:", use_colors=use_colors), file=out) for tool_call in outputs[0]["tool_calls"]: - print(_green(f"{tool_call['function']['name']}: {tool_call['function']['arguments']}", use_colors=use_colors), file=out) + print(_green(f"{tool_call.name}: {tool_call.args}", use_colors=use_colors), file=out) else: print(_red("Response:", use_colors=use_colors), file=out) print(_green(outputs[0].strip(), use_colors=use_colors), file=out) diff --git a/tests/adapters/test_chat_adapter.py b/tests/adapters/test_chat_adapter.py index 86c662b3ba..4698dbb05a 100644 --- a/tests/adapters/test_chat_adapter.py +++ b/tests/adapters/test_chat_adapter.py @@ -562,7 +562,13 @@ def get_weather(city: str) -> str: ) assert result[0]["tool_calls"] == dspy.ToolCalls( - tool_calls=[dspy.ToolCalls.ToolCall(name="get_weather", args={"city": "Paris"})] + tool_calls=[ + dspy.ToolCalls.ToolCall( + name="get_weather", + args={"city": "Paris"}, + id="call_pQm8ajtSMxgA0nrzK2ivFmxG", + ) + ] ) # `answer` is not present, so we set it to None assert result[0]["answer"] is None @@ -782,13 +788,22 @@ def test_empty_string_content_raises_adapter_parse_error(): def test_tool_call_with_null_content_does_not_raise(): """Tool-call-only responses legitimately have content=None. - _call_postprocess must NOT raise when tool_calls are present.""" + _call_postprocess must NOT raise when tool_calls are present. + + NOTE: tool_calls are passed as `list[ToolCalls.ToolCall]` because + `BaseLM` normalizes the wire shape at the LiteLLM boundary via + `to_tool_call`; postprocess never sees raw OpenAI dicts.""" adapter = dspy.ChatAdapter(use_native_function_calling=True) sig_cls = dspy.Signature("question, tools: list[dspy.Tool] -> answer, tool_calls: dspy.ToolCalls") - outputs = [{"text": None, "tool_calls": [ - {"function": {"name": "search", "arguments": '{"query": "test"}'}, "id": "call_1", "type": "function"} - ]}] + outputs = [ + { + "text": None, + "tool_calls": [ + dspy.ToolCalls.ToolCall(name="search", args={"query": "test"}, id="call_1") + ], + } + ] result = adapter._call_postprocess(sig_cls, sig_cls, outputs, None, {}) assert result is not None diff --git a/tests/adapters/test_json_adapter.py b/tests/adapters/test_json_adapter.py index f376831d98..2d21a54300 100644 --- a/tests/adapters/test_json_adapter.py +++ b/tests/adapters/test_json_adapter.py @@ -833,7 +833,13 @@ def get_weather(city: str) -> str: ) assert result[0]["tool_calls"] == dspy.ToolCalls( - tool_calls=[dspy.ToolCalls.ToolCall(name="get_weather", args={"city": "Paris"})] + tool_calls=[ + dspy.ToolCalls.ToolCall( + name="get_weather", + args={"city": "Paris"}, + id="call_pQm8ajtSMxgA0nrzK2ivFmxG", + ) + ] ) # `answer` is not present, so we set it to None assert result[0]["answer"] is None diff --git a/tests/adapters/test_tool_call_normalization.py b/tests/adapters/test_tool_call_normalization.py new file mode 100644 index 0000000000..1169daeb3e --- /dev/null +++ b/tests/adapters/test_tool_call_normalization.py @@ -0,0 +1,162 @@ +"""Tests for `to_tool_call`, the single boundary function that normalizes +every LiteLLM tool-call wire shape into a canonical `ToolCall`. + +After this normalization, no downstream code should need to handle multiple +wire shapes. These tests pin down exactly which inputs the boundary accepts +and what it produces. +""" + +import pytest + +from dspy.adapters.types.tool import ToolCalls, to_tool_call + +# ---------- Shape 1: OpenAI Chat Completions ---------- + +def test_chat_completions_dict_shape(): + item = { + "id": "call_abc", + "type": "function", + "function": {"name": "search", "arguments": '{"q":"hello"}'}, + } + tc = to_tool_call(item) + assert isinstance(tc, ToolCalls.ToolCall) + assert tc.name == "search" + assert tc.args == {"q": "hello"} + assert tc.id == "call_abc" + + +def test_chat_completions_pydantic_shape(): + """Real LiteLLM `ChatCompletionMessageToolCall` is a pydantic object; + we go through model_dump first.""" + class Fn: + name = "search" + arguments = '{"q":"x"}' + + class CCMToolCall: + id = "call_123" + type = "function" + function = Fn() + + def model_dump(self): + return { + "id": self.id, + "type": self.type, + "function": {"name": self.function.name, "arguments": self.function.arguments}, + } + + tc = to_tool_call(CCMToolCall()) + assert tc.name == "search" + assert tc.args == {"q": "x"} + assert tc.id == "call_123" + + +def test_chat_completions_arguments_as_dict(): + """Some providers (and our own round-trips) put `arguments` as a dict.""" + item = {"type": "function", "function": {"name": "lookup", "arguments": {"k": "v"}}} + tc = to_tool_call(item) + assert tc.args == {"k": "v"} + + +def test_chat_completions_empty_arguments_string(): + """`arguments=""` should normalize to `{}`, not crash.""" + item = {"type": "function", "function": {"name": "ping", "arguments": ""}} + assert to_tool_call(item).args == {} + + +# ---------- Shape 2: OpenAI Responses API ---------- + +def test_responses_api_dict_shape(): + item = { + "type": "function_call", + "name": "search", + "arguments": '{"q":"y"}', + "call_id": "call_xyz", + } + tc = to_tool_call(item) + assert tc.name == "search" + assert tc.args == {"q": "y"} + assert tc.id == "call_xyz" + + +def test_responses_api_pydantic_shape(): + class FunctionCallItem: + type = "function_call" + name = "search" + arguments = '{"q":"z"}' + call_id = "call_99" + + def model_dump(self): + return { + "type": self.type, + "name": self.name, + "arguments": self.arguments, + "call_id": self.call_id, + } + + tc = to_tool_call(FunctionCallItem()) + assert tc.name == "search" + assert tc.args == {"q": "z"} + assert tc.id == "call_99" + + +# ---------- Shape 3: MockValSer / SchemaSerializer fallback ---------- + +def test_mockvalser_fallback_chat_completions_shape(): + """Cached LiteLLM pydantic whose model_dump raises TypeError — must fall + back to attribute access via the `function` attribute. + + See https://github.com/pydantic/pydantic/issues/7713 + and https://github.com/BerriAI/litellm/issues/9345 + """ + class Fn: + name = "search" + arguments = '{"q":"cached"}' + + class CachedToolCall: + id = "call_cached" + type = "function" + function = Fn() + + def model_dump(self): + raise TypeError("'MockValSer' object cannot be converted to 'SchemaSerializer'") + + tc = to_tool_call(CachedToolCall()) + assert tc.name == "search" + assert tc.args == {"q": "cached"} + assert tc.id == "call_cached" + + +def test_mockvalser_fallback_no_recoverable_attrs_raises(): + """If model_dump fails AND there's no `function` or `name` attribute, + re-raise the original MockValSer TypeError instead of returning garbage.""" + class Unsalvageable: + def model_dump(self): + raise TypeError("'MockValSer' object cannot be converted to 'SchemaSerializer'") + + with pytest.raises(TypeError, match="MockValSer"): + to_tool_call(Unsalvageable()) + + +# ---------- Error reporting ---------- + +def test_unknown_dict_shape_raises_with_payload(): + with pytest.raises(ValueError, match="Unknown tool-call shape"): + to_tool_call({"unrelated": "data"}) + + +def test_non_dict_non_pydantic_raises_with_type_info(): + with pytest.raises(TypeError, match="Cannot normalize tool call from int"): + to_tool_call(42) + + +# ---------- ToolCall.id field ---------- + +def test_toolcall_id_field_optional(): + """ToolCall.id should default to None when not provided.""" + tc = ToolCalls.ToolCall(name="f", args={}) + assert tc.id is None + + +def test_toolcall_id_round_trips(): + tc = ToolCalls.ToolCall(name="f", args={"a": 1}, id="call_zzz") + assert tc.id == "call_zzz" From 992222df4ecdb8cdf2117cbf4182510aec0d0c3c Mon Sep 17 00:00:00 2001 From: isaacbmiller Date: Tue, 12 May 2026 15:26:05 -0400 Subject: [PATCH 02/14] refactor(base_type): move BaseLM type-only import to TYPE_CHECKING MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `base_type.py` only used `BaseLM` as a parameter annotation on `adapt_to_native_lm_feature`, so it didn't need a runtime import. Moving it under `TYPE_CHECKING` breaks the `base_lm → tool → base_type → base_lm` cycle, letting `base_lm.py` import `to_tool_call` at module level instead of lazily inside two hot paths. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- dspy/adapters/types/base_type.py | 5 ++--- dspy/clients/base_lm.py | 3 +-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/dspy/adapters/types/base_type.py b/dspy/adapters/types/base_type.py index 13a55727f2..bc4c9b7397 100644 --- a/dspy/adapters/types/base_type.py +++ b/dspy/adapters/types/base_type.py @@ -5,11 +5,10 @@ import json_repair import pydantic -from dspy.clients.base_lm import BaseLM - if TYPE_CHECKING: from litellm import ModelResponseStream + from dspy.clients.base_lm import BaseLM from dspy.signatures.signature import Signature CUSTOM_TYPE_START_IDENTIFIER = "<>" @@ -81,7 +80,7 @@ def adapt_to_native_lm_feature( cls, signature: type["Signature"], field_name: str, - lm: BaseLM, + lm: "BaseLM", lm_kwargs: dict[str, Any], ) -> type["Signature"]: """Adapt the custom type to the native LM feature if possible. diff --git a/dspy/clients/base_lm.py b/dspy/clients/base_lm.py index 52b7995758..68b47a7da9 100644 --- a/dspy/clients/base_lm.py +++ b/dspy/clients/base_lm.py @@ -2,6 +2,7 @@ import uuid from typing import Any, TextIO +from dspy.adapters.types.tool import to_tool_call from dspy.dsp.utils import settings from dspy.utils.callback import with_callbacks from dspy.utils.inspect_history import pretty_print_history @@ -267,7 +268,6 @@ def _process_completion(self, response, merged_kwargs): 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): - from dspy.adapters.types.tool import to_tool_call # avoid circular import output["tool_calls"] = [to_tool_call(tc) for tc in c.message.tool_calls] # Extract citations from LiteLLM response if available @@ -320,7 +320,6 @@ def _process_response(self, response): for content_item in output_item.content: text_outputs.append(content_item.text) elif output_item_type == "function_call": - from dspy.adapters.types.tool import to_tool_call # avoid circular import tool_calls.append(to_tool_call(output_item)) elif output_item_type == "reasoning": if getattr(output_item, "content", None) and len(output_item.content) > 0: From 62a072461d2724ae5641b65846ad3b52a8db66ca Mon Sep 17 00:00:00 2001 From: isaacbmiller Date: Tue, 12 May 2026 15:43:55 -0400 Subject: [PATCH 03/14] refactor(tool-call): own both boundaries at the clients layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the canonical `ToolCall` data type and the inbound `to_tool_call` boundary into a new `dspy/clients/tool_call.py`. The dependency now flows `adapters → clients`, which is the correct direction; `base_lm.py` imports from a same-layer sibling instead of reaching up into the adapter layer (and no lazy imports / TYPE_CHECKING hacks needed). `ToolCalls` keeps the same public API: `dspy.ToolCalls` is still the adapter-layer `Type` used in signatures, and `ToolCalls.ToolCall` remains a usable handle via a `ClassVar` alias for the clients-layer type. Adds outbound symmetry: `Tool.format_as_litellm_function_call(model_type)` now emits the flattened Responses-API shape (`{type:'function', name, description, parameters}`) when `model_type='responses'` and the wrapped Chat-Completions shape otherwise. `adapters/base.py` threads `lm.model_type` in. Postprocess fix: both `Adapter._call_postprocess` and `TwoStepAdapter` now use `output.get('text')` instead of `output['text']`. The Responses API path omits the `text` key entirely when an output is tool-calls-only, so the previous direct subscript crashed with KeyError. Verified end-to-end against live gpt-5-nano on both `model_type='chat'` and `model_type='responses'` paths through `ChatAdapter(use_native_function_calling=True)`. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- dspy/adapters/base.py | 7 +- dspy/adapters/two_step_adapter.py | 2 +- dspy/adapters/types/tool.py | 141 +++--------------- dspy/clients/base_lm.py | 2 +- dspy/clients/tool_call.py | 104 +++++++++++++ .../adapters/test_tool_call_normalization.py | 40 ++++- 6 files changed, 172 insertions(+), 124 deletions(-) create mode 100644 dspy/clients/tool_call.py diff --git a/dspy/adapters/base.py b/dspy/adapters/base.py index d9527559a4..723388d5ee 100644 --- a/dspy/adapters/base.py +++ b/dspy/adapters/base.py @@ -83,7 +83,7 @@ def _call_preprocess( tools = inputs[tool_call_input_field_name] tools = tools if isinstance(tools, list) else [tools] - lm_tools = [tool.format_as_litellm_function_call() for tool in tools] + lm_tools = [tool.format_as_litellm_function_call(model_type=lm.model_type) for tool in tools] lm_kwargs["tools"] = lm_tools @@ -123,7 +123,10 @@ def _call_postprocess( text = output if isinstance(output, dict): - text = output["text"] + # The Responses API path can produce tool-calls-only outputs + # with no `text` key; the Chat Completions path always sets `text` + # (possibly to None). Use .get() so both surface uniformly. + text = output.get("text") output_logprobs = output.get("logprobs") tool_calls = output.get("tool_calls") diff --git a/dspy/adapters/two_step_adapter.py b/dspy/adapters/two_step_adapter.py index f2b9408e4c..c13d0bd394 100644 --- a/dspy/adapters/two_step_adapter.py +++ b/dspy/adapters/two_step_adapter.py @@ -124,7 +124,7 @@ async def acall( text = output if isinstance(output, dict): - text = output["text"] + text = output.get("text") output_logprobs = output.get("logprobs") tool_calls = output.get("tool_calls") diff --git a/dspy/adapters/types/tool.py b/dspy/adapters/types/tool.py index 2f57f6341b..77a0d52694 100644 --- a/dspy/adapters/types/tool.py +++ b/dspy/adapters/types/tool.py @@ -1,13 +1,13 @@ import asyncio import inspect -from typing import TYPE_CHECKING, Any, Callable, get_origin, get_type_hints +from typing import TYPE_CHECKING, Any, Callable, ClassVar, get_origin, get_type_hints -import json_repair import pydantic from jsonschema import ValidationError, validate from pydantic import BaseModel, TypeAdapter, create_model from dspy.adapters.types.base_type import Type +from dspy.clients.tool_call import ToolCall from dspy.dsp.utils.settings import settings from dspy.utils.callback import with_callbacks @@ -149,19 +149,26 @@ def _validate_and_parse_args(self, **kwargs): def format(self): return str(self) - def format_as_litellm_function_call(self): - return { - "type": "function", - "function": { - "name": self.name, - "description": self.desc, - "parameters": { - "type": "object", - "properties": self.args, - "required": list(self.args.keys()), - }, + def format_as_litellm_function_call(self, model_type: str = "chat") -> dict[str, Any]: + """Single outbound boundary for serializing this tool to a LiteLLM payload. + + Chat / text completions expect the OpenAI Chat Completions wrapper + (``{type: "function", function: {name, description, parameters}}``); + the Responses API expects the flattened shape + (``{type: "function", name, description, parameters}``). + """ + fn = { + "name": self.name, + "description": self.desc, + "parameters": { + "type": "object", + "properties": self.args, + "required": list(self.args.keys()), }, } + if model_type == "responses": + return {"type": "function", **fn} + return {"type": "function", "function": fn} def _run_async_in_sync(self, coroutine): try: @@ -260,112 +267,10 @@ def __str__(self): return f"{self.name}{desc} {arg_desc}" -def to_tool_call(item: Any) -> "ToolCalls.ToolCall": - """Normalize a LiteLLM tool-call into a canonical ``ToolCall``. - - Single boundary for wire-shape coercion. Falls back to attribute - access when ``model_dump()`` raises ``TypeError`` because of the - MockValSer/SchemaSerializer bug (pydantic#7713, litellm#9345). - """ - if not isinstance(item, dict) and hasattr(item, "model_dump"): - try: - item = item.model_dump() - except TypeError: - fn = getattr(item, "function", None) - if fn is not None: - return ToolCalls.ToolCall( - name=fn.name, args=_parse_args(fn.arguments), id=getattr(item, "id", None) - ) - if getattr(item, "name", None) is None: - raise - return ToolCalls.ToolCall( - name=item.name, - args=_parse_args(getattr(item, "arguments", None)), - id=getattr(item, "call_id", None) or getattr(item, "id", None), - ) - - if not isinstance(item, dict): - raise TypeError(f"Cannot normalize tool call from {type(item).__name__}: {item!r}") - - if item.get("type") == "function" and isinstance(item.get("function"), dict): - fn = item["function"] - return ToolCalls.ToolCall(name=fn["name"], args=_parse_args(fn.get("arguments")), id=item.get("id")) - - if item.get("type") == "function_call" and item.get("name"): - return ToolCalls.ToolCall( - name=item["name"], - args=_parse_args(item.get("arguments")), - id=item.get("call_id") or item.get("id"), - ) - - raise ValueError(f"Unknown tool-call shape: {item!r}") - - -def _parse_args(args: Any) -> dict[str, Any]: - if args is None or args == "": - return {} - return json_repair.loads(args) if isinstance(args, str) else args - - class ToolCalls(Type): - class ToolCall(Type): - name: str - args: dict[str, Any] - id: str | None = None - - def format(self): - return { - "type": "function", - "function": { - "name": self.name, - "arguments": self.args, - }, - } - - def execute(self, functions: dict[str, Any] | list[Tool] | None = None) -> Any: - """Execute this individual tool call and return its result. - - Args: - functions: Functions to search for the tool. Can be: - - Dict mapping tool names to functions: {"tool_name": function} - - List of Tool objects: [Tool(function), ...] - - None: Will search in caller's locals and globals (automatic lookup) - - Returns: - The result from executing this tool call. - - Raises: - ValueError: If the tool function cannot be found. - Exception: Any exception raised by the tool function. - """ - func = None - - if functions is None: - # Automatic lookup in caller's globals and locals - frame = inspect.currentframe().f_back - try: - caller_globals = frame.f_globals - caller_locals = frame.f_locals - func = caller_locals.get(self.name) or caller_globals.get(self.name) - finally: - del frame - - elif isinstance(functions, dict): - func = functions.get(self.name) - elif isinstance(functions, list): - for tool in functions: - if tool.name == self.name: - func = tool.func - break - - if func is None: - raise ValueError(f"Tool function '{self.name}' not found. Please pass the tool functions to the `execute` method.") - - try: - args = self.args or {} - return func(**args) - except Exception as e: - raise RuntimeError(f"Error executing tool '{self.name}': {e}") from e + # Backwards-compat alias: keep `dspy.ToolCalls.ToolCall(...)` working. + # The canonical data type lives in `dspy.clients.tool_call`. + ToolCall: ClassVar[type[ToolCall]] = ToolCall tool_calls: list[ToolCall] diff --git a/dspy/clients/base_lm.py b/dspy/clients/base_lm.py index 68b47a7da9..dbf04625bf 100644 --- a/dspy/clients/base_lm.py +++ b/dspy/clients/base_lm.py @@ -2,7 +2,7 @@ import uuid from typing import Any, TextIO -from dspy.adapters.types.tool import to_tool_call +from dspy.clients.tool_call import to_tool_call from dspy.dsp.utils import settings from dspy.utils.callback import with_callbacks from dspy.utils.inspect_history import pretty_print_history diff --git a/dspy/clients/tool_call.py b/dspy/clients/tool_call.py new file mode 100644 index 0000000000..3714203adf --- /dev/null +++ b/dspy/clients/tool_call.py @@ -0,0 +1,104 @@ +"""Canonical tool-call data type and inbound wire-shape boundary. + +Lives at the ``clients`` layer (the LiteLLM wrapper) so the dependency +direction is ``adapters → clients``, never the reverse. Outbound (DSPy +``Tool`` → wire shape) is handled by ``Tool.format_as_litellm_function_call`` +parameterized by ``model_type``. +""" + +import inspect +from typing import Any + +import json_repair +import pydantic + + +class ToolCall(pydantic.BaseModel): + name: str + args: dict[str, Any] + id: str | None = None + + def format(self) -> dict[str, Any]: + return { + "type": "function", + "function": {"name": self.name, "arguments": self.args}, + } + + def execute(self, functions: Any = None) -> Any: + """Execute this tool call. + + ``functions`` may be a ``{name: callable}`` dict, a list of objects + with ``.name`` and ``.func`` attributes (e.g. ``dspy.Tool``), or + ``None`` to look the name up in the caller's locals/globals. + """ + func = None + + if functions is None: + frame = inspect.currentframe().f_back + try: + func = frame.f_locals.get(self.name) or frame.f_globals.get(self.name) + finally: + del frame + elif isinstance(functions, dict): + func = functions.get(self.name) + elif isinstance(functions, list): + for tool in functions: + if tool.name == self.name: + func = tool.func + break + + if func is None: + raise ValueError( + f"Tool function '{self.name}' not found. " + "Please pass the tool functions to the `execute` method." + ) + + try: + return func(**(self.args or {})) + except Exception as e: + raise RuntimeError(f"Error executing tool '{self.name}': {e}") from e + + +def to_tool_call(item: Any) -> ToolCall: + """Normalize a LiteLLM tool-call into a canonical ``ToolCall``. + + Single inbound boundary for wire-shape coercion. Falls back to attribute + access when ``model_dump()`` raises ``TypeError`` because of the + MockValSer/SchemaSerializer bug (pydantic#7713, litellm#9345). + """ + if not isinstance(item, dict) and hasattr(item, "model_dump"): + try: + item = item.model_dump() + except TypeError: + fn = getattr(item, "function", None) + if fn is not None: + return ToolCall(name=fn.name, args=_parse_args(fn.arguments), id=getattr(item, "id", None)) + if getattr(item, "name", None) is None: + raise + return ToolCall( + name=item.name, + args=_parse_args(getattr(item, "arguments", None)), + id=getattr(item, "call_id", None) or getattr(item, "id", None), + ) + + if not isinstance(item, dict): + raise TypeError(f"Cannot normalize tool call from {type(item).__name__}: {item!r}") + + if item.get("type") == "function" and isinstance(item.get("function"), dict): + fn = item["function"] + return ToolCall(name=fn["name"], args=_parse_args(fn.get("arguments")), id=item.get("id")) + + if item.get("type") == "function_call" and item.get("name"): + return ToolCall( + name=item["name"], + args=_parse_args(item.get("arguments")), + id=item.get("call_id") or item.get("id"), + ) + + raise ValueError(f"Unknown tool-call shape: {item!r}") + + +def _parse_args(args: Any) -> dict[str, Any]: + if args is None or args == "": + return {} + return json_repair.loads(args) if isinstance(args, str) else args diff --git a/tests/adapters/test_tool_call_normalization.py b/tests/adapters/test_tool_call_normalization.py index 1169daeb3e..46b3aee0b7 100644 --- a/tests/adapters/test_tool_call_normalization.py +++ b/tests/adapters/test_tool_call_normalization.py @@ -8,7 +8,8 @@ import pytest -from dspy.adapters.types.tool import ToolCalls, to_tool_call +from dspy.adapters.types.tool import ToolCalls +from dspy.clients.tool_call import ToolCall, to_tool_call # ---------- Shape 1: OpenAI Chat Completions ---------- @@ -19,7 +20,8 @@ def test_chat_completions_dict_shape(): "function": {"name": "search", "arguments": '{"q":"hello"}'}, } tc = to_tool_call(item) - assert isinstance(tc, ToolCalls.ToolCall) + assert isinstance(tc, ToolCall) + assert ToolCalls.ToolCall is ToolCall # backward-compat alias assert tc.name == "search" assert tc.args == {"q": "hello"} assert tc.id == "call_abc" @@ -160,3 +162,37 @@ def test_toolcall_id_field_optional(): def test_toolcall_id_round_trips(): tc = ToolCalls.ToolCall(name="f", args={"a": 1}, id="call_zzz") assert tc.id == "call_zzz" + + +# ---------- Outbound boundary: Tool -> LiteLLM wire shape ---------- + +def test_tool_format_chat_completions_shape(): + import dspy + + tool = dspy.Tool(lambda city: city, name="get_weather", desc="weather") + payload = tool.format_as_litellm_function_call(model_type="chat") + assert payload["type"] == "function" + assert "function" in payload + assert payload["function"]["name"] == "get_weather" + assert payload["function"]["description"] == "weather" + assert "parameters" in payload["function"] + + +def test_tool_format_responses_api_shape(): + import dspy + + tool = dspy.Tool(lambda city: city, name="get_weather", desc="weather") + payload = tool.format_as_litellm_function_call(model_type="responses") + assert payload["type"] == "function" + # Responses API flattens: name/description/parameters at top level, no `function` wrapper. + assert "function" not in payload + assert payload["name"] == "get_weather" + assert payload["description"] == "weather" + assert "parameters" in payload + + +def test_tool_format_default_is_chat(): + import dspy + + tool = dspy.Tool(lambda city: city, name="x", desc="d") + assert tool.format_as_litellm_function_call() == tool.format_as_litellm_function_call(model_type="chat") From c950e08912be0135e2029d29868e83bf425ecef3 Mon Sep 17 00:00:00 2001 From: isaacbmiller Date: Tue, 12 May 2026 15:58:34 -0400 Subject: [PATCH 04/14] refactor(tool-call): hoist ToolCall to public surface, drop dead code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Re-export `ToolCall` at the standard public locations: `dspy.ToolCall`, `dspy.adapters.ToolCall`, `dspy.adapters.types.ToolCall`. Backward-compat access via `dspy.ToolCalls.ToolCall` keeps working — the ClassVar alias resolves to the same class object, so `isinstance` checks across either path are equivalent. - Restore the top-level `from dspy.clients.base_lm import BaseLM` in `adapters/types/base_type.py`. The cycle dissolved when `base_lm.py` stopped importing from `adapters/types/tool.py` (it now imports from the leaf module `clients/tool_call.py`), so the `TYPE_CHECKING` workaround is no longer needed. - Delete `ToolCalls.from_dict_list` and its test. The method was a thin wrapper around the canonical `ToolCalls(tool_calls=[ToolCall(**d) ...])` constructor and its only remaining caller was a test of itself; its docstring also referenced a parameter name (`dict_list`) that didn't match the signature. Removed the matching API doc entry. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- docs/docs/api/primitives/ToolCalls.md | 1 - dspy/__init__.py | 2 +- dspy/adapters/__init__.py | 3 ++- dspy/adapters/types/__init__.py | 3 ++- dspy/adapters/types/base_type.py | 5 +++-- dspy/adapters/types/tool.py | 23 ----------------------- tests/adapters/test_tool.py | 15 --------------- 7 files changed, 8 insertions(+), 44 deletions(-) diff --git a/docs/docs/api/primitives/ToolCalls.md b/docs/docs/api/primitives/ToolCalls.md index 350bf0a9c8..99a5a19028 100644 --- a/docs/docs/api/primitives/ToolCalls.md +++ b/docs/docs/api/primitives/ToolCalls.md @@ -9,7 +9,6 @@ - description - extract_custom_type_from_annotation - format - - from_dict_list - is_streamable - parse_lm_response - parse_stream_chunk diff --git a/dspy/__init__.py b/dspy/__init__.py index 76afdb805d..82f1f89a26 100644 --- a/dspy/__init__.py +++ b/dspy/__init__.py @@ -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, Reasoning # isort: skip +from dspy.adapters import Adapter, ChatAdapter, JSONAdapter, XMLAdapter, TwoStepAdapter, Image, Audio, File, History, Type, Tool, ToolCall, ToolCalls, Code, Reasoning # isort: skip from dspy.primitives.sandbox_serializable import SandboxSerializable # isort: skip from dspy.utils.exceptions import ContextWindowExceededError from dspy.utils.logging_utils import configure_dspy_loggers, disable_logging, enable_logging diff --git a/dspy/adapters/__init__.py b/dspy/adapters/__init__.py index c217d7260e..e0aad0a602 100644 --- a/dspy/adapters/__init__.py +++ b/dspy/adapters/__init__.py @@ -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, Reasoning, Tool, ToolCalls, Type +from dspy.adapters.types import Audio, Code, File, History, Image, Reasoning, Tool, ToolCall, ToolCalls, Type from dspy.adapters.xml_adapter import XMLAdapter __all__ = [ @@ -18,6 +18,7 @@ "XMLAdapter", "TwoStepAdapter", "Tool", + "ToolCall", "ToolCalls", "Reasoning", ] diff --git a/dspy/adapters/types/__init__.py b/dspy/adapters/types/__init__.py index 5ec8043021..b5a89c36d7 100644 --- a/dspy/adapters/types/__init__.py +++ b/dspy/adapters/types/__init__.py @@ -6,5 +6,6 @@ from dspy.adapters.types.image import Image from dspy.adapters.types.reasoning import Reasoning from dspy.adapters.types.tool import Tool, ToolCalls +from dspy.clients.tool_call import ToolCall -__all__ = ["History", "Image", "Audio", "File", "Type", "Tool", "ToolCalls", "Code", "Reasoning"] +__all__ = ["History", "Image", "Audio", "File", "Type", "Tool", "ToolCall", "ToolCalls", "Code", "Reasoning"] diff --git a/dspy/adapters/types/base_type.py b/dspy/adapters/types/base_type.py index bc4c9b7397..13a55727f2 100644 --- a/dspy/adapters/types/base_type.py +++ b/dspy/adapters/types/base_type.py @@ -5,10 +5,11 @@ import json_repair import pydantic +from dspy.clients.base_lm import BaseLM + if TYPE_CHECKING: from litellm import ModelResponseStream - from dspy.clients.base_lm import BaseLM from dspy.signatures.signature import Signature CUSTOM_TYPE_START_IDENTIFIER = "<>" @@ -80,7 +81,7 @@ def adapt_to_native_lm_feature( cls, signature: type["Signature"], field_name: str, - lm: "BaseLM", + lm: BaseLM, lm_kwargs: dict[str, Any], ) -> type["Signature"]: """Adapt the custom type to the native LM feature if possible. diff --git a/dspy/adapters/types/tool.py b/dspy/adapters/types/tool.py index 77a0d52694..3e7c185da0 100644 --- a/dspy/adapters/types/tool.py +++ b/dspy/adapters/types/tool.py @@ -274,29 +274,6 @@ class ToolCalls(Type): tool_calls: list[ToolCall] - @classmethod - def from_dict_list(cls, tool_calls_dicts: list[dict[str, Any]]) -> "ToolCalls": - """Convert a list of dictionaries to a ToolCalls instance. - - Args: - dict_list: A list of dictionaries, where each dictionary should have 'name' and 'args' keys. - - Returns: - A ToolCalls instance. - - Examples: - - ```python - tool_calls_dict = [ - {"name": "search", "args": {"query": "hello"}}, - {"name": "translate", "args": {"text": "world"}} - ] - tool_calls = ToolCalls.from_dict_list(tool_calls_dict) - ``` - """ - tool_calls = [cls.ToolCall(**item) for item in tool_calls_dicts] - return cls(tool_calls=tool_calls) - @classmethod def description(cls) -> str: return ( diff --git a/tests/adapters/test_tool.py b/tests/adapters/test_tool.py index cfcffe0947..c50fd23174 100644 --- a/tests/adapters/test_tool.py +++ b/tests/adapters/test_tool.py @@ -435,21 +435,6 @@ def test_tool_calls_format_basic(tool_calls_data, expected): assert result == expected -def test_tool_calls_format_from_dict_list(): - """Test format works with ToolCalls created from from_dict_list.""" - tool_calls_dicts = [ - {"name": "search", "args": {"query": "hello"}}, - {"name": "translate", "args": {"text": "world", "lang": "fr"}}, - ] - - tool_calls = ToolCalls.from_dict_list(tool_calls_dicts) - result = tool_calls.format() - - assert len(result["tool_calls"]) == 2 - assert result["tool_calls"][0]["function"]["name"] == "search" - assert result["tool_calls"][1]["function"]["name"] == "translate" - - def test_toolcalls_vague_match(): """ Test that ToolCalls can parse the data with slightly off format: From d55a83aa813a3159c47407fda37770afec3dd760 Mon Sep 17 00:00:00 2001 From: isaacbmiller Date: Tue, 12 May 2026 16:00:48 -0400 Subject: [PATCH 05/14] fix(tool-call): preserve id in format() so the round-trip is total `ToolCall.format()` dropped `self.id`, which meant `to_tool_call(tc.format())` lost the provider call-id we just added the field for. Include `id` in the payload when set; omit the key when absent to avoid fabricating one on the wire. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- dspy/clients/tool_call.py | 5 ++++- tests/adapters/test_tool_call_normalization.py | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/dspy/clients/tool_call.py b/dspy/clients/tool_call.py index 3714203adf..15c5a26464 100644 --- a/dspy/clients/tool_call.py +++ b/dspy/clients/tool_call.py @@ -19,10 +19,13 @@ class ToolCall(pydantic.BaseModel): id: str | None = None def format(self) -> dict[str, Any]: - return { + payload: dict[str, Any] = { "type": "function", "function": {"name": self.name, "arguments": self.args}, } + if self.id is not None: + payload["id"] = self.id + return payload def execute(self, functions: Any = None) -> Any: """Execute this tool call. diff --git a/tests/adapters/test_tool_call_normalization.py b/tests/adapters/test_tool_call_normalization.py index 46b3aee0b7..ff96b5a384 100644 --- a/tests/adapters/test_tool_call_normalization.py +++ b/tests/adapters/test_tool_call_normalization.py @@ -164,6 +164,19 @@ def test_toolcall_id_round_trips(): assert tc.id == "call_zzz" +def test_toolcall_format_preserves_id_for_round_trip(): + original = ToolCall(name="search", args={"q": "hello"}, id="call_xyz") + restored = to_tool_call(original.format()) + assert restored == original + + +def test_toolcall_format_omits_id_when_absent(): + """A ToolCall constructed without an id should serialize without an `id` key + so we don't fabricate one on the wire.""" + payload = ToolCall(name="search", args={"q": "x"}).format() + assert "id" not in payload + + # ---------- Outbound boundary: Tool -> LiteLLM wire shape ---------- def test_tool_format_chat_completions_shape(): From 1eb23ffc3c6a134693c8d55a76f467f5404bcc97 Mon Sep 17 00:00:00 2001 From: isaacbmiller Date: Tue, 12 May 2026 16:07:23 -0400 Subject: [PATCH 06/14] refactor(tool-call): colocate ToolCall with ToolCalls in adapters/types/tool.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves ToolCall and to_tool_call back into dspy/adapters/types/tool.py so the three tool-call types (Tool, ToolCall, ToolCalls) live in one file. base_lm.py imports to_tool_call lazily inside its two hot paths to avoid the base_lm → adapters/tool → base_type → base_lm import cycle; base_type.py keeps BaseLM under TYPE_CHECKING for the same reason. Adds a TODO(MaximeRivest) marker on ToolCall and at the lazy-import sites flagging that this interface should move to ToolPart, which will let us collapse the lazy imports. Public surface is unchanged: dspy.ToolCall, dspy.ToolCalls.ToolCall, and dspy.adapters.{ToolCall,types.ToolCall} all resolve to the same class object. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- dspy/adapters/types/__init__.py | 3 +- dspy/adapters/types/base_type.py | 5 +- dspy/adapters/types/tool.py | 106 ++++++++++++++++- dspy/clients/base_lm.py | 7 +- dspy/clients/tool_call.py | 107 ------------------ .../adapters/test_tool_call_normalization.py | 3 +- 6 files changed, 114 insertions(+), 117 deletions(-) delete mode 100644 dspy/clients/tool_call.py diff --git a/dspy/adapters/types/__init__.py b/dspy/adapters/types/__init__.py index b5a89c36d7..41ffe5cbf2 100644 --- a/dspy/adapters/types/__init__.py +++ b/dspy/adapters/types/__init__.py @@ -5,7 +5,6 @@ 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 -from dspy.clients.tool_call import ToolCall +from dspy.adapters.types.tool import Tool, ToolCall, ToolCalls __all__ = ["History", "Image", "Audio", "File", "Type", "Tool", "ToolCall", "ToolCalls", "Code", "Reasoning"] diff --git a/dspy/adapters/types/base_type.py b/dspy/adapters/types/base_type.py index 13a55727f2..bc4c9b7397 100644 --- a/dspy/adapters/types/base_type.py +++ b/dspy/adapters/types/base_type.py @@ -5,11 +5,10 @@ import json_repair import pydantic -from dspy.clients.base_lm import BaseLM - if TYPE_CHECKING: from litellm import ModelResponseStream + from dspy.clients.base_lm import BaseLM from dspy.signatures.signature import Signature CUSTOM_TYPE_START_IDENTIFIER = "<>" @@ -81,7 +80,7 @@ def adapt_to_native_lm_feature( cls, signature: type["Signature"], field_name: str, - lm: BaseLM, + lm: "BaseLM", lm_kwargs: dict[str, Any], ) -> type["Signature"]: """Adapt the custom type to the native LM feature if possible. diff --git a/dspy/adapters/types/tool.py b/dspy/adapters/types/tool.py index 3e7c185da0..2de213bb65 100644 --- a/dspy/adapters/types/tool.py +++ b/dspy/adapters/types/tool.py @@ -2,12 +2,12 @@ import inspect from typing import TYPE_CHECKING, Any, Callable, ClassVar, get_origin, get_type_hints +import json_repair import pydantic from jsonschema import ValidationError, validate from pydantic import BaseModel, TypeAdapter, create_model from dspy.adapters.types.base_type import Type -from dspy.clients.tool_call import ToolCall from dspy.dsp.utils.settings import settings from dspy.utils.callback import with_callbacks @@ -267,9 +267,111 @@ def __str__(self): return f"{self.name}{desc} {arg_desc}" +# TODO(MaximeRivest): Change this interface to use ToolPart. +class ToolCall(pydantic.BaseModel): + """Canonical in-process representation of a single tool call. + + Lives here (alongside ``ToolCalls`` and ``Tool``) so all tool-call + types are co-located. ``dspy.clients.base_lm`` imports + ``to_tool_call`` lazily inside its hot paths to avoid the + ``base_lm → tool → base_type → base_lm`` import cycle. + """ + + name: str + args: dict[str, Any] + id: str | None = None + + def format(self) -> dict[str, Any]: + payload: dict[str, Any] = { + "type": "function", + "function": {"name": self.name, "arguments": self.args}, + } + if self.id is not None: + payload["id"] = self.id + return payload + + def execute(self, functions: Any = None) -> Any: + """Execute this tool call. + + ``functions`` may be a ``{name: callable}`` dict, a list of objects + with ``.name`` and ``.func`` attributes (e.g. ``dspy.Tool``), or + ``None`` to look the name up in the caller's locals/globals. + """ + func = None + + if functions is None: + frame = inspect.currentframe().f_back + try: + func = frame.f_locals.get(self.name) or frame.f_globals.get(self.name) + finally: + del frame + elif isinstance(functions, dict): + func = functions.get(self.name) + elif isinstance(functions, list): + for tool in functions: + if tool.name == self.name: + func = tool.func + break + + if func is None: + raise ValueError( + f"Tool function '{self.name}' not found. " + "Please pass the tool functions to the `execute` method." + ) + + try: + return func(**(self.args or {})) + except Exception as e: + raise RuntimeError(f"Error executing tool '{self.name}': {e}") from e + + +def to_tool_call(item: Any) -> ToolCall: + """Normalize a LiteLLM tool-call into a canonical ``ToolCall``. + + Single inbound boundary for wire-shape coercion. Falls back to attribute + access when ``model_dump()`` raises ``TypeError`` because of the + MockValSer/SchemaSerializer bug (pydantic#7713, litellm#9345). + """ + if not isinstance(item, dict) and hasattr(item, "model_dump"): + try: + item = item.model_dump() + except TypeError: + fn = getattr(item, "function", None) + if fn is not None: + return ToolCall(name=fn.name, args=_parse_args(fn.arguments), id=getattr(item, "id", None)) + if getattr(item, "name", None) is None: + raise + return ToolCall( + name=item.name, + args=_parse_args(getattr(item, "arguments", None)), + id=getattr(item, "call_id", None) or getattr(item, "id", None), + ) + + if not isinstance(item, dict): + raise TypeError(f"Cannot normalize tool call from {type(item).__name__}: {item!r}") + + if item.get("type") == "function" and isinstance(item.get("function"), dict): + fn = item["function"] + return ToolCall(name=fn["name"], args=_parse_args(fn.get("arguments")), id=item.get("id")) + + if item.get("type") == "function_call" and item.get("name"): + return ToolCall( + name=item["name"], + args=_parse_args(item.get("arguments")), + id=item.get("call_id") or item.get("id"), + ) + + raise ValueError(f"Unknown tool-call shape: {item!r}") + + +def _parse_args(args: Any) -> dict[str, Any]: + if args is None or args == "": + return {} + return json_repair.loads(args) if isinstance(args, str) else args + + class ToolCalls(Type): # Backwards-compat alias: keep `dspy.ToolCalls.ToolCall(...)` working. - # The canonical data type lives in `dspy.clients.tool_call`. ToolCall: ClassVar[type[ToolCall]] = ToolCall tool_calls: list[ToolCall] diff --git a/dspy/clients/base_lm.py b/dspy/clients/base_lm.py index dbf04625bf..5623f31ba2 100644 --- a/dspy/clients/base_lm.py +++ b/dspy/clients/base_lm.py @@ -2,7 +2,6 @@ import uuid from typing import Any, TextIO -from dspy.clients.tool_call import to_tool_call from dspy.dsp.utils import settings from dspy.utils.callback import with_callbacks from dspy.utils.inspect_history import pretty_print_history @@ -268,6 +267,9 @@ def _process_completion(self, response, merged_kwargs): 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): + # TODO(MaximeRivest): Change this interface to use ToolPart. + # Lazy import avoids `base_lm → adapters/tool → base_type → base_lm`. + from dspy.adapters.types.tool import to_tool_call output["tool_calls"] = [to_tool_call(tc) for tc in c.message.tool_calls] # Extract citations from LiteLLM response if available @@ -320,6 +322,9 @@ def _process_response(self, response): for content_item in output_item.content: text_outputs.append(content_item.text) elif output_item_type == "function_call": + # TODO(MaximeRivest): Change this interface to use ToolPart. + # Lazy import avoids `base_lm → adapters/tool → base_type → base_lm`. + from dspy.adapters.types.tool import to_tool_call tool_calls.append(to_tool_call(output_item)) elif output_item_type == "reasoning": if getattr(output_item, "content", None) and len(output_item.content) > 0: diff --git a/dspy/clients/tool_call.py b/dspy/clients/tool_call.py deleted file mode 100644 index 15c5a26464..0000000000 --- a/dspy/clients/tool_call.py +++ /dev/null @@ -1,107 +0,0 @@ -"""Canonical tool-call data type and inbound wire-shape boundary. - -Lives at the ``clients`` layer (the LiteLLM wrapper) so the dependency -direction is ``adapters → clients``, never the reverse. Outbound (DSPy -``Tool`` → wire shape) is handled by ``Tool.format_as_litellm_function_call`` -parameterized by ``model_type``. -""" - -import inspect -from typing import Any - -import json_repair -import pydantic - - -class ToolCall(pydantic.BaseModel): - name: str - args: dict[str, Any] - id: str | None = None - - def format(self) -> dict[str, Any]: - payload: dict[str, Any] = { - "type": "function", - "function": {"name": self.name, "arguments": self.args}, - } - if self.id is not None: - payload["id"] = self.id - return payload - - def execute(self, functions: Any = None) -> Any: - """Execute this tool call. - - ``functions`` may be a ``{name: callable}`` dict, a list of objects - with ``.name`` and ``.func`` attributes (e.g. ``dspy.Tool``), or - ``None`` to look the name up in the caller's locals/globals. - """ - func = None - - if functions is None: - frame = inspect.currentframe().f_back - try: - func = frame.f_locals.get(self.name) or frame.f_globals.get(self.name) - finally: - del frame - elif isinstance(functions, dict): - func = functions.get(self.name) - elif isinstance(functions, list): - for tool in functions: - if tool.name == self.name: - func = tool.func - break - - if func is None: - raise ValueError( - f"Tool function '{self.name}' not found. " - "Please pass the tool functions to the `execute` method." - ) - - try: - return func(**(self.args or {})) - except Exception as e: - raise RuntimeError(f"Error executing tool '{self.name}': {e}") from e - - -def to_tool_call(item: Any) -> ToolCall: - """Normalize a LiteLLM tool-call into a canonical ``ToolCall``. - - Single inbound boundary for wire-shape coercion. Falls back to attribute - access when ``model_dump()`` raises ``TypeError`` because of the - MockValSer/SchemaSerializer bug (pydantic#7713, litellm#9345). - """ - if not isinstance(item, dict) and hasattr(item, "model_dump"): - try: - item = item.model_dump() - except TypeError: - fn = getattr(item, "function", None) - if fn is not None: - return ToolCall(name=fn.name, args=_parse_args(fn.arguments), id=getattr(item, "id", None)) - if getattr(item, "name", None) is None: - raise - return ToolCall( - name=item.name, - args=_parse_args(getattr(item, "arguments", None)), - id=getattr(item, "call_id", None) or getattr(item, "id", None), - ) - - if not isinstance(item, dict): - raise TypeError(f"Cannot normalize tool call from {type(item).__name__}: {item!r}") - - if item.get("type") == "function" and isinstance(item.get("function"), dict): - fn = item["function"] - return ToolCall(name=fn["name"], args=_parse_args(fn.get("arguments")), id=item.get("id")) - - if item.get("type") == "function_call" and item.get("name"): - return ToolCall( - name=item["name"], - args=_parse_args(item.get("arguments")), - id=item.get("call_id") or item.get("id"), - ) - - raise ValueError(f"Unknown tool-call shape: {item!r}") - - -def _parse_args(args: Any) -> dict[str, Any]: - if args is None or args == "": - return {} - return json_repair.loads(args) if isinstance(args, str) else args diff --git a/tests/adapters/test_tool_call_normalization.py b/tests/adapters/test_tool_call_normalization.py index ff96b5a384..ab8dab3335 100644 --- a/tests/adapters/test_tool_call_normalization.py +++ b/tests/adapters/test_tool_call_normalization.py @@ -8,8 +8,7 @@ import pytest -from dspy.adapters.types.tool import ToolCalls -from dspy.clients.tool_call import ToolCall, to_tool_call +from dspy.adapters.types.tool import ToolCall, ToolCalls, to_tool_call # ---------- Shape 1: OpenAI Chat Completions ---------- From ac97d05e4bb4df5e1c3458fdaa47d101a1159cf3 Mon Sep 17 00:00:00 2001 From: isaacbmiller Date: Tue, 12 May 2026 16:19:53 -0400 Subject: [PATCH 07/14] refactor(tool-call): nest ToolCall inside ToolCalls, drop module-level alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ToolCall is now defined inline at the top of the ToolCalls class body — one class definition, adjacent to the container it belongs to. No separate module-level class, no module-level `ToolCall = ToolCalls.ToolCall` alias, no public re-exports at `dspy` / `dspy.adapters` / `dspy.adapters.types`. The canonical access path is now `dspy.ToolCalls.ToolCall`. `to_tool_call` stays a module-level function (it's a boundary, not a constructor) and binds `ToolCall = ToolCalls.ToolCall` locally for readability. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- dspy/__init__.py | 2 +- dspy/adapters/__init__.py | 3 +- dspy/adapters/types/__init__.py | 4 +- dspy/adapters/types/tool.py | 171 +++++++++--------- .../adapters/test_tool_call_normalization.py | 5 +- 5 files changed, 88 insertions(+), 97 deletions(-) diff --git a/dspy/__init__.py b/dspy/__init__.py index 82f1f89a26..76afdb805d 100644 --- a/dspy/__init__.py +++ b/dspy/__init__.py @@ -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, ToolCall, ToolCalls, Code, Reasoning # isort: skip +from dspy.adapters import Adapter, ChatAdapter, JSONAdapter, XMLAdapter, TwoStepAdapter, Image, Audio, File, History, Type, Tool, ToolCalls, Code, Reasoning # isort: skip from dspy.primitives.sandbox_serializable import SandboxSerializable # isort: skip from dspy.utils.exceptions import ContextWindowExceededError from dspy.utils.logging_utils import configure_dspy_loggers, disable_logging, enable_logging diff --git a/dspy/adapters/__init__.py b/dspy/adapters/__init__.py index e0aad0a602..c217d7260e 100644 --- a/dspy/adapters/__init__.py +++ b/dspy/adapters/__init__.py @@ -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, Reasoning, Tool, ToolCall, ToolCalls, Type +from dspy.adapters.types import Audio, Code, File, History, Image, Reasoning, Tool, ToolCalls, Type from dspy.adapters.xml_adapter import XMLAdapter __all__ = [ @@ -18,7 +18,6 @@ "XMLAdapter", "TwoStepAdapter", "Tool", - "ToolCall", "ToolCalls", "Reasoning", ] diff --git a/dspy/adapters/types/__init__.py b/dspy/adapters/types/__init__.py index 41ffe5cbf2..5ec8043021 100644 --- a/dspy/adapters/types/__init__.py +++ b/dspy/adapters/types/__init__.py @@ -5,6 +5,6 @@ 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, ToolCall, ToolCalls +from dspy.adapters.types.tool import Tool, ToolCalls -__all__ = ["History", "Image", "Audio", "File", "Type", "Tool", "ToolCall", "ToolCalls", "Code", "Reasoning"] +__all__ = ["History", "Image", "Audio", "File", "Type", "Tool", "ToolCalls", "Code", "Reasoning"] diff --git a/dspy/adapters/types/tool.py b/dspy/adapters/types/tool.py index 2de213bb65..1eee3983ad 100644 --- a/dspy/adapters/types/tool.py +++ b/dspy/adapters/types/tool.py @@ -1,6 +1,6 @@ import asyncio import inspect -from typing import TYPE_CHECKING, Any, Callable, ClassVar, get_origin, get_type_hints +from typing import TYPE_CHECKING, Any, Callable, get_origin, get_type_hints import json_repair import pydantic @@ -149,6 +149,7 @@ def _validate_and_parse_args(self, **kwargs): def format(self): return str(self) + # TODO(MaximeRivest): Change to be to_LMToolCallPart def format_as_litellm_function_call(self, model_type: str = "chat") -> dict[str, Any]: """Single outbound boundary for serializing this tool to a LiteLLM payload. @@ -267,71 +268,98 @@ def __str__(self): return f"{self.name}{desc} {arg_desc}" -# TODO(MaximeRivest): Change this interface to use ToolPart. -class ToolCall(pydantic.BaseModel): - """Canonical in-process representation of a single tool call. - - Lives here (alongside ``ToolCalls`` and ``Tool``) so all tool-call - types are co-located. ``dspy.clients.base_lm`` imports - ``to_tool_call`` lazily inside its hot paths to avoid the - ``base_lm → tool → base_type → base_lm`` import cycle. - """ +class ToolCalls(Type): + class ToolCall(pydantic.BaseModel): + name: str + args: dict[str, Any] + id: str | None = None + + def format(self) -> dict[str, Any]: + payload: dict[str, Any] = { + "type": "function", + "function": {"name": self.name, "arguments": self.args}, + } + if self.id is not None: + payload["id"] = self.id + return payload + + def execute(self, functions: Any = None) -> Any: + """Execute this tool call. + + ``functions`` may be a ``{name: callable}`` dict, a list of objects + with ``.name`` and ``.func`` attributes (e.g. ``dspy.Tool``), or + ``None`` to look the name up in the caller's locals/globals. + """ + func = None + + if functions is None: + frame = inspect.currentframe().f_back + try: + func = frame.f_locals.get(self.name) or frame.f_globals.get(self.name) + finally: + del frame + elif isinstance(functions, dict): + func = functions.get(self.name) + elif isinstance(functions, list): + for tool in functions: + if tool.name == self.name: + func = tool.func + break + + if func is None: + raise ValueError( + f"Tool function '{self.name}' not found. " + "Please pass the tool functions to the `execute` method." + ) - name: str - args: dict[str, Any] - id: str | None = None + try: + return func(**(self.args or {})) + except Exception as e: + raise RuntimeError(f"Error executing tool '{self.name}': {e}") from e - def format(self) -> dict[str, Any]: - payload: dict[str, Any] = { - "type": "function", - "function": {"name": self.name, "arguments": self.args}, - } - if self.id is not None: - payload["id"] = self.id - return payload + tool_calls: list[ToolCall] - def execute(self, functions: Any = None) -> Any: - """Execute this tool call. + @classmethod + def description(cls) -> str: + return ( + "Tool calls information, including the name of the tools and the arguments to be passed to it. " + "Arguments must be provided in JSON format." + ) - ``functions`` may be a ``{name: callable}`` dict, a list of objects - with ``.name`` and ``.func`` attributes (e.g. ``dspy.Tool``), or - ``None`` to look the name up in the caller's locals/globals. - """ - func = None + def format(self) -> list[dict[str, Any]]: + # The tool_call field is compatible with OpenAI's tool calls schema. + return { + "tool_calls": [tool_call.format() for tool_call in self.tool_calls], + } - if functions is None: - frame = inspect.currentframe().f_back - try: - func = frame.f_locals.get(self.name) or frame.f_globals.get(self.name) - finally: - del frame - elif isinstance(functions, dict): - func = functions.get(self.name) - elif isinstance(functions, list): - for tool in functions: - if tool.name == self.name: - func = tool.func - break - - if func is None: - raise ValueError( - f"Tool function '{self.name}' not found. " - "Please pass the tool functions to the `execute` method." - ) + @pydantic.model_validator(mode="before") + @classmethod + def validate_input(cls, data: Any): + def coerce(items): + return [it if isinstance(it, cls.ToolCall) else cls.ToolCall(**it) for it in items] - try: - return func(**(self.args or {})) - except Exception as e: - raise RuntimeError(f"Error executing tool '{self.name}': {e}") from e + if isinstance(data, cls): + return data + if isinstance(data, list): + return {"tool_calls": coerce(data)} + if isinstance(data, dict): + if "tool_calls" in data: + return {"tool_calls": coerce(data["tool_calls"])} + if "name" in data and "args" in data: + return {"tool_calls": [cls.ToolCall(**data)]} + raise ValueError(f"Invalid value for `dspy.ToolCalls`: {data!r}") -def to_tool_call(item: Any) -> ToolCall: - """Normalize a LiteLLM tool-call into a canonical ``ToolCall``. +# TODO(MaximeRivest): Change this interface to be from_LMToolResultPart. +def to_tool_call(item: Any) -> ToolCalls.ToolCall: + """Normalize a LiteLLM tool-call into a canonical ``ToolCalls.ToolCall``. Single inbound boundary for wire-shape coercion. Falls back to attribute access when ``model_dump()`` raises ``TypeError`` because of the MockValSer/SchemaSerializer bug (pydantic#7713, litellm#9345). """ + ToolCall = ToolCalls.ToolCall + if not isinstance(item, dict) and hasattr(item, "model_dump"): try: item = item.model_dump() @@ -370,43 +398,6 @@ def _parse_args(args: Any) -> dict[str, Any]: return json_repair.loads(args) if isinstance(args, str) else args -class ToolCalls(Type): - # Backwards-compat alias: keep `dspy.ToolCalls.ToolCall(...)` working. - ToolCall: ClassVar[type[ToolCall]] = ToolCall - - tool_calls: list[ToolCall] - - @classmethod - def description(cls) -> str: - return ( - "Tool calls information, including the name of the tools and the arguments to be passed to it. " - "Arguments must be provided in JSON format." - ) - - def format(self) -> list[dict[str, Any]]: - # The tool_call field is compatible with OpenAI's tool calls schema. - return { - "tool_calls": [tool_call.format() for tool_call in self.tool_calls], - } - - @pydantic.model_validator(mode="before") - @classmethod - def validate_input(cls, data: Any): - def coerce(items): - return [it if isinstance(it, cls.ToolCall) else cls.ToolCall(**it) for it in items] - - if isinstance(data, cls): - return data - if isinstance(data, list): - return {"tool_calls": coerce(data)} - if isinstance(data, dict): - if "tool_calls" in data: - return {"tool_calls": coerce(data["tool_calls"])} - if "name" in data and "args" in data: - return {"tool_calls": [cls.ToolCall(**data)]} - raise ValueError(f"Invalid value for `dspy.ToolCalls`: {data!r}") - - def _resolve_json_schema_reference(schema: dict) -> dict: """Recursively resolve json model schema, expanding all references.""" diff --git a/tests/adapters/test_tool_call_normalization.py b/tests/adapters/test_tool_call_normalization.py index ab8dab3335..16ea74bad1 100644 --- a/tests/adapters/test_tool_call_normalization.py +++ b/tests/adapters/test_tool_call_normalization.py @@ -8,7 +8,9 @@ import pytest -from dspy.adapters.types.tool import ToolCall, ToolCalls, to_tool_call +from dspy.adapters.types.tool import ToolCalls, to_tool_call + +ToolCall = ToolCalls.ToolCall # ---------- Shape 1: OpenAI Chat Completions ---------- @@ -20,7 +22,6 @@ def test_chat_completions_dict_shape(): } tc = to_tool_call(item) assert isinstance(tc, ToolCall) - assert ToolCalls.ToolCall is ToolCall # backward-compat alias assert tc.name == "search" assert tc.args == {"q": "hello"} assert tc.id == "call_abc" From bbe5f50346872182a5880ad69a4d12e5a1b2d36e Mon Sep 17 00:00:00 2001 From: isaacbmiller Date: Tue, 12 May 2026 16:23:29 -0400 Subject: [PATCH 08/14] fix(tool-call): inline ToolCalls.ToolCall refs in to_tool_call; restore execute docstring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dropped the local `ToolCall = ToolCalls.ToolCall` rebind in `to_tool_call` — the rebind was just noise. Refer to `ToolCalls.ToolCall` directly. - Restored the original `execute` docstring (Args/Returns/Raises) and the precise `dict[str, Any] | list[Tool] | None` type annotation that predated this PR. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- dspy/adapters/types/tool.py | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/dspy/adapters/types/tool.py b/dspy/adapters/types/tool.py index 1eee3983ad..265c9b7c8d 100644 --- a/dspy/adapters/types/tool.py +++ b/dspy/adapters/types/tool.py @@ -283,12 +283,21 @@ def format(self) -> dict[str, Any]: payload["id"] = self.id return payload - def execute(self, functions: Any = None) -> Any: - """Execute this tool call. + def execute(self, functions: "dict[str, Any] | list[Tool] | None" = None) -> Any: + """Execute this individual tool call and return its result. - ``functions`` may be a ``{name: callable}`` dict, a list of objects - with ``.name`` and ``.func`` attributes (e.g. ``dspy.Tool``), or - ``None`` to look the name up in the caller's locals/globals. + Args: + functions: Functions to search for the tool. Can be: + - Dict mapping tool names to functions: {"tool_name": function} + - List of Tool objects: [Tool(function), ...] + - None: Will search in caller's locals and globals (automatic lookup) + + Returns: + The result from executing this tool call. + + Raises: + ValueError: If the tool function cannot be found. + Exception: Any exception raised by the tool function. """ func = None @@ -358,18 +367,16 @@ def to_tool_call(item: Any) -> ToolCalls.ToolCall: access when ``model_dump()`` raises ``TypeError`` because of the MockValSer/SchemaSerializer bug (pydantic#7713, litellm#9345). """ - ToolCall = ToolCalls.ToolCall - if not isinstance(item, dict) and hasattr(item, "model_dump"): try: item = item.model_dump() except TypeError: fn = getattr(item, "function", None) if fn is not None: - return ToolCall(name=fn.name, args=_parse_args(fn.arguments), id=getattr(item, "id", None)) + return ToolCalls.ToolCall(name=fn.name, args=_parse_args(fn.arguments), id=getattr(item, "id", None)) if getattr(item, "name", None) is None: raise - return ToolCall( + return ToolCalls.ToolCall( name=item.name, args=_parse_args(getattr(item, "arguments", None)), id=getattr(item, "call_id", None) or getattr(item, "id", None), @@ -380,10 +387,10 @@ def to_tool_call(item: Any) -> ToolCalls.ToolCall: if item.get("type") == "function" and isinstance(item.get("function"), dict): fn = item["function"] - return ToolCall(name=fn["name"], args=_parse_args(fn.get("arguments")), id=item.get("id")) + return ToolCalls.ToolCall(name=fn["name"], args=_parse_args(fn.get("arguments")), id=item.get("id")) if item.get("type") == "function_call" and item.get("name"): - return ToolCall( + return ToolCalls.ToolCall( name=item["name"], args=_parse_args(item.get("arguments")), id=item.get("call_id") or item.get("id"), From e5e040f504c85a1a9d8d5bd5eebfeeebe4b9c5fd Mon Sep 17 00:00:00 2001 From: isaacbmiller Date: Tue, 12 May 2026 16:32:43 -0400 Subject: [PATCH 09/14] test(tool-call): fold normalization tests into test_tool.py Tool, ToolCalls, and to_tool_call all live in dspy/adapters/types/tool.py so their tests live in a single tests/adapters/test_tool.py file. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- tests/adapters/test_tool.py | 190 +++++++++++++++- .../adapters/test_tool_call_normalization.py | 211 ------------------ 2 files changed, 189 insertions(+), 212 deletions(-) delete mode 100644 tests/adapters/test_tool_call_normalization.py diff --git a/tests/adapters/test_tool.py b/tests/adapters/test_tool.py index c50fd23174..ff8b1c44c8 100644 --- a/tests/adapters/test_tool.py +++ b/tests/adapters/test_tool.py @@ -5,7 +5,7 @@ from pydantic import BaseModel import dspy -from dspy.adapters.types.tool import Tool, ToolCalls, convert_input_schema_to_tool_args +from dspy.adapters.types.tool import Tool, ToolCalls, convert_input_schema_to_tool_args, to_tool_call # Test fixtures @@ -594,3 +594,191 @@ def local_multiply(x: int, y: int) -> int: globals().pop("local_add", None) main() + + +# ============================================================================= +# `to_tool_call`: the single inbound boundary that normalizes every LiteLLM +# tool-call wire shape into a canonical `ToolCalls.ToolCall`. After this +# normalization, no downstream code should need to handle multiple wire shapes. +# ============================================================================= + + +def test_to_tool_call_chat_completions_dict_shape(): + item = { + "id": "call_abc", + "type": "function", + "function": {"name": "search", "arguments": '{"q":"hello"}'}, + } + tc = to_tool_call(item) + assert isinstance(tc, ToolCalls.ToolCall) + assert tc.name == "search" + assert tc.args == {"q": "hello"} + assert tc.id == "call_abc" + + +def test_to_tool_call_chat_completions_pydantic_shape(): + """Real LiteLLM `ChatCompletionMessageToolCall` is a pydantic object; + we go through model_dump first.""" + class Fn: + name = "search" + arguments = '{"q":"x"}' + + class CCMToolCall: + id = "call_123" + type = "function" + function = Fn() + + def model_dump(self): + return { + "id": self.id, + "type": self.type, + "function": {"name": self.function.name, "arguments": self.function.arguments}, + } + + tc = to_tool_call(CCMToolCall()) + assert tc.name == "search" + assert tc.args == {"q": "x"} + assert tc.id == "call_123" + + +def test_to_tool_call_chat_completions_arguments_as_dict(): + """Some providers (and our own round-trips) put `arguments` as a dict.""" + item = {"type": "function", "function": {"name": "lookup", "arguments": {"k": "v"}}} + tc = to_tool_call(item) + assert tc.args == {"k": "v"} + + +def test_to_tool_call_chat_completions_empty_arguments_string(): + """`arguments=""` should normalize to `{}`, not crash.""" + item = {"type": "function", "function": {"name": "ping", "arguments": ""}} + assert to_tool_call(item).args == {} + + +def test_to_tool_call_responses_api_dict_shape(): + item = { + "type": "function_call", + "name": "search", + "arguments": '{"q":"y"}', + "call_id": "call_xyz", + } + tc = to_tool_call(item) + assert tc.name == "search" + assert tc.args == {"q": "y"} + assert tc.id == "call_xyz" + + +def test_to_tool_call_responses_api_pydantic_shape(): + class FunctionCallItem: + type = "function_call" + name = "search" + arguments = '{"q":"z"}' + call_id = "call_99" + + def model_dump(self): + return { + "type": self.type, + "name": self.name, + "arguments": self.arguments, + "call_id": self.call_id, + } + + tc = to_tool_call(FunctionCallItem()) + assert tc.name == "search" + assert tc.args == {"q": "z"} + assert tc.id == "call_99" + + +def test_to_tool_call_mockvalser_fallback_chat_completions_shape(): + """Cached LiteLLM pydantic whose model_dump raises TypeError — must fall + back to attribute access via the `function` attribute. + + See https://github.com/pydantic/pydantic/issues/7713 + and https://github.com/BerriAI/litellm/issues/9345 + """ + class Fn: + name = "search" + arguments = '{"q":"cached"}' + + class CachedToolCall: + id = "call_cached" + type = "function" + function = Fn() + + def model_dump(self): + raise TypeError("'MockValSer' object cannot be converted to 'SchemaSerializer'") + + tc = to_tool_call(CachedToolCall()) + assert tc.name == "search" + assert tc.args == {"q": "cached"} + assert tc.id == "call_cached" + + +def test_to_tool_call_mockvalser_fallback_no_recoverable_attrs_raises(): + """If model_dump fails AND there's no `function` or `name` attribute, + re-raise the original MockValSer TypeError instead of returning garbage.""" + class Unsalvageable: + def model_dump(self): + raise TypeError("'MockValSer' object cannot be converted to 'SchemaSerializer'") + + with pytest.raises(TypeError, match="MockValSer"): + to_tool_call(Unsalvageable()) + + +def test_to_tool_call_unknown_dict_shape_raises_with_payload(): + with pytest.raises(ValueError, match="Unknown tool-call shape"): + to_tool_call({"unrelated": "data"}) + + +def test_to_tool_call_non_dict_non_pydantic_raises_with_type_info(): + with pytest.raises(TypeError, match="Cannot normalize tool call from int"): + to_tool_call(42) + + +def test_toolcall_id_field_optional(): + """ToolCall.id should default to None when not provided.""" + tc = ToolCalls.ToolCall(name="f", args={}) + assert tc.id is None + + +def test_toolcall_id_round_trips(): + tc = ToolCalls.ToolCall(name="f", args={"a": 1}, id="call_zzz") + assert tc.id == "call_zzz" + + +def test_toolcall_format_preserves_id_for_round_trip(): + original = ToolCalls.ToolCall(name="search", args={"q": "hello"}, id="call_xyz") + restored = to_tool_call(original.format()) + assert restored == original + + +def test_toolcall_format_omits_id_when_absent(): + """A ToolCall constructed without an id should serialize without an `id` key + so we don't fabricate one on the wire.""" + payload = ToolCalls.ToolCall(name="search", args={"q": "x"}).format() + assert "id" not in payload + + +def test_tool_format_chat_completions_shape(): + tool = dspy.Tool(lambda city: city, name="get_weather", desc="weather") + payload = tool.format_as_litellm_function_call(model_type="chat") + assert payload["type"] == "function" + assert "function" in payload + assert payload["function"]["name"] == "get_weather" + assert payload["function"]["description"] == "weather" + assert "parameters" in payload["function"] + + +def test_tool_format_responses_api_shape(): + tool = dspy.Tool(lambda city: city, name="get_weather", desc="weather") + payload = tool.format_as_litellm_function_call(model_type="responses") + assert payload["type"] == "function" + # Responses API flattens: name/description/parameters at top level, no `function` wrapper. + assert "function" not in payload + assert payload["name"] == "get_weather" + assert payload["description"] == "weather" + assert "parameters" in payload + + +def test_tool_format_default_is_chat(): + tool = dspy.Tool(lambda city: city, name="x", desc="d") + assert tool.format_as_litellm_function_call() == tool.format_as_litellm_function_call(model_type="chat") diff --git a/tests/adapters/test_tool_call_normalization.py b/tests/adapters/test_tool_call_normalization.py deleted file mode 100644 index 16ea74bad1..0000000000 --- a/tests/adapters/test_tool_call_normalization.py +++ /dev/null @@ -1,211 +0,0 @@ -"""Tests for `to_tool_call`, the single boundary function that normalizes -every LiteLLM tool-call wire shape into a canonical `ToolCall`. - -After this normalization, no downstream code should need to handle multiple -wire shapes. These tests pin down exactly which inputs the boundary accepts -and what it produces. -""" - -import pytest - -from dspy.adapters.types.tool import ToolCalls, to_tool_call - -ToolCall = ToolCalls.ToolCall - -# ---------- Shape 1: OpenAI Chat Completions ---------- - -def test_chat_completions_dict_shape(): - item = { - "id": "call_abc", - "type": "function", - "function": {"name": "search", "arguments": '{"q":"hello"}'}, - } - tc = to_tool_call(item) - assert isinstance(tc, ToolCall) - assert tc.name == "search" - assert tc.args == {"q": "hello"} - assert tc.id == "call_abc" - - -def test_chat_completions_pydantic_shape(): - """Real LiteLLM `ChatCompletionMessageToolCall` is a pydantic object; - we go through model_dump first.""" - class Fn: - name = "search" - arguments = '{"q":"x"}' - - class CCMToolCall: - id = "call_123" - type = "function" - function = Fn() - - def model_dump(self): - return { - "id": self.id, - "type": self.type, - "function": {"name": self.function.name, "arguments": self.function.arguments}, - } - - tc = to_tool_call(CCMToolCall()) - assert tc.name == "search" - assert tc.args == {"q": "x"} - assert tc.id == "call_123" - - -def test_chat_completions_arguments_as_dict(): - """Some providers (and our own round-trips) put `arguments` as a dict.""" - item = {"type": "function", "function": {"name": "lookup", "arguments": {"k": "v"}}} - tc = to_tool_call(item) - assert tc.args == {"k": "v"} - - -def test_chat_completions_empty_arguments_string(): - """`arguments=""` should normalize to `{}`, not crash.""" - item = {"type": "function", "function": {"name": "ping", "arguments": ""}} - assert to_tool_call(item).args == {} - - -# ---------- Shape 2: OpenAI Responses API ---------- - -def test_responses_api_dict_shape(): - item = { - "type": "function_call", - "name": "search", - "arguments": '{"q":"y"}', - "call_id": "call_xyz", - } - tc = to_tool_call(item) - assert tc.name == "search" - assert tc.args == {"q": "y"} - assert tc.id == "call_xyz" - - -def test_responses_api_pydantic_shape(): - class FunctionCallItem: - type = "function_call" - name = "search" - arguments = '{"q":"z"}' - call_id = "call_99" - - def model_dump(self): - return { - "type": self.type, - "name": self.name, - "arguments": self.arguments, - "call_id": self.call_id, - } - - tc = to_tool_call(FunctionCallItem()) - assert tc.name == "search" - assert tc.args == {"q": "z"} - assert tc.id == "call_99" - - -# ---------- Shape 3: MockValSer / SchemaSerializer fallback ---------- - -def test_mockvalser_fallback_chat_completions_shape(): - """Cached LiteLLM pydantic whose model_dump raises TypeError — must fall - back to attribute access via the `function` attribute. - - See https://github.com/pydantic/pydantic/issues/7713 - and https://github.com/BerriAI/litellm/issues/9345 - """ - class Fn: - name = "search" - arguments = '{"q":"cached"}' - - class CachedToolCall: - id = "call_cached" - type = "function" - function = Fn() - - def model_dump(self): - raise TypeError("'MockValSer' object cannot be converted to 'SchemaSerializer'") - - tc = to_tool_call(CachedToolCall()) - assert tc.name == "search" - assert tc.args == {"q": "cached"} - assert tc.id == "call_cached" - - -def test_mockvalser_fallback_no_recoverable_attrs_raises(): - """If model_dump fails AND there's no `function` or `name` attribute, - re-raise the original MockValSer TypeError instead of returning garbage.""" - class Unsalvageable: - def model_dump(self): - raise TypeError("'MockValSer' object cannot be converted to 'SchemaSerializer'") - - with pytest.raises(TypeError, match="MockValSer"): - to_tool_call(Unsalvageable()) - - -# ---------- Error reporting ---------- - -def test_unknown_dict_shape_raises_with_payload(): - with pytest.raises(ValueError, match="Unknown tool-call shape"): - to_tool_call({"unrelated": "data"}) - - -def test_non_dict_non_pydantic_raises_with_type_info(): - with pytest.raises(TypeError, match="Cannot normalize tool call from int"): - to_tool_call(42) - - -# ---------- ToolCall.id field ---------- - -def test_toolcall_id_field_optional(): - """ToolCall.id should default to None when not provided.""" - tc = ToolCalls.ToolCall(name="f", args={}) - assert tc.id is None - - -def test_toolcall_id_round_trips(): - tc = ToolCalls.ToolCall(name="f", args={"a": 1}, id="call_zzz") - assert tc.id == "call_zzz" - - -def test_toolcall_format_preserves_id_for_round_trip(): - original = ToolCall(name="search", args={"q": "hello"}, id="call_xyz") - restored = to_tool_call(original.format()) - assert restored == original - - -def test_toolcall_format_omits_id_when_absent(): - """A ToolCall constructed without an id should serialize without an `id` key - so we don't fabricate one on the wire.""" - payload = ToolCall(name="search", args={"q": "x"}).format() - assert "id" not in payload - - -# ---------- Outbound boundary: Tool -> LiteLLM wire shape ---------- - -def test_tool_format_chat_completions_shape(): - import dspy - - tool = dspy.Tool(lambda city: city, name="get_weather", desc="weather") - payload = tool.format_as_litellm_function_call(model_type="chat") - assert payload["type"] == "function" - assert "function" in payload - assert payload["function"]["name"] == "get_weather" - assert payload["function"]["description"] == "weather" - assert "parameters" in payload["function"] - - -def test_tool_format_responses_api_shape(): - import dspy - - tool = dspy.Tool(lambda city: city, name="get_weather", desc="weather") - payload = tool.format_as_litellm_function_call(model_type="responses") - assert payload["type"] == "function" - # Responses API flattens: name/description/parameters at top level, no `function` wrapper. - assert "function" not in payload - assert payload["name"] == "get_weather" - assert payload["description"] == "weather" - assert "parameters" in payload - - -def test_tool_format_default_is_chat(): - import dspy - - tool = dspy.Tool(lambda city: city, name="x", desc="d") - assert tool.format_as_litellm_function_call() == tool.format_as_litellm_function_call(model_type="chat") From 0fdd1ac52610a63a4bc61b8039c85a859c6a6b2b Mon Sep 17 00:00:00 2001 From: isaacbmiller Date: Tue, 12 May 2026 16:40:07 -0400 Subject: [PATCH 10/14] test(lm): update test_responses_api_tool_calls for canonical ToolCall The boundary in dspy/adapters/types/tool.py::to_tool_call now normalizes both Chat Completions and Responses API tool-call wire shapes into a canonical ToolCalls.ToolCall before they leave the LM layer. Update the test's expected value to assert the canonical shape instead of the raw Responses-API dict, which was the very thing the boundary was added to eliminate. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- tests/clients/test_lm.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/clients/test_lm.py b/tests/clients/test_lm.py index 13a7e96eff..3b8ff389d5 100644 --- a/tests/clients/test_lm.py +++ b/tests/clients/test_lm.py @@ -610,7 +610,7 @@ def test_lm_replaces_system_with_developer_role(): def test_responses_api_tool_calls(litellm_test_server): api_base, _ = litellm_test_server - expected_tool_call = { + wire_tool_call = { "type": "function_call", "name": "get_weather", "arguments": json.dumps({"city": "Paris"}), @@ -618,10 +618,19 @@ def test_responses_api_tool_calls(litellm_test_server): "status": "completed", "id": "call_1", } - expected_response = [{"tool_calls": [expected_tool_call]}] + # `lm()` returns canonical `ToolCalls.ToolCall` objects regardless of which + # wire shape (Chat Completions vs Responses API) the provider used. The + # boundary that does this normalization is `dspy.adapters.types.tool.to_tool_call`. + expected_response = [ + { + "tool_calls": [ + dspy.ToolCalls.ToolCall(name="get_weather", args={"city": "Paris"}, id="call_1") + ] + } + ] api_response = make_response( - output_blocks=[expected_tool_call], + output_blocks=[wire_tool_call], ) with mock.patch("litellm.responses", autospec=True, return_value=api_response) as dspy_responses: From 00724430a97f82ff5d015acee564e01455412e8d Mon Sep 17 00:00:00 2001 From: isaacbmiller Date: Tue, 12 May 2026 16:53:39 -0400 Subject: [PATCH 11/14] fix(tool-call): JSON-encode arguments in format(); use .get("text") in inspect_history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues flagged in prior review passes: 1. `ToolCalls.ToolCall.format()` serialized `function.arguments` as a Python dict. OpenAI Chat Completions requires it to be a JSON-encoded string when the payload is replayed as an assistant tool-call message (e.g. `{"role":"assistant","tool_calls":[...]}`). Switched to `json.dumps(self.args)`. `to_tool_call` already accepts both shapes on the way in, so the round-trip stays total — verified by the existing `test_toolcall_format_preserves_id_for_round_trip` test. 2. `pretty_print_history` did `outputs[0]["text"]`, which raises `KeyError` for Responses API outputs that contain only tool calls (no `text` key). Switched to `.get("text")`, matching the same fix applied in `adapters/base.py` earlier in this PR. Added regression tests for both: * `test_toolcall_format_arguments_is_json_string_for_openai_assistant_message` * `test_toolcall_format_empty_args_is_json_object_string` * `test_pretty_print_history_handles_tool_calls_only_output` Updated the existing `TOOL_CALL_TEST_CASES` parametrization to expect JSON-string arguments. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- dspy/adapters/types/tool.py | 7 ++++- dspy/utils/inspect_history.py | 7 +++-- tests/adapters/test_tool.py | 28 +++++++++++++++++--- tests/clients/test_inspect_global_history.py | 24 +++++++++++++++++ 4 files changed, 59 insertions(+), 7 deletions(-) diff --git a/dspy/adapters/types/tool.py b/dspy/adapters/types/tool.py index 265c9b7c8d..32551654a6 100644 --- a/dspy/adapters/types/tool.py +++ b/dspy/adapters/types/tool.py @@ -1,5 +1,6 @@ import asyncio import inspect +import json from typing import TYPE_CHECKING, Any, Callable, get_origin, get_type_hints import json_repair @@ -275,9 +276,13 @@ class ToolCall(pydantic.BaseModel): id: str | None = None def format(self) -> dict[str, Any]: + # OpenAI Chat Completions requires `function.arguments` to be a + # JSON-encoded string when this payload is replayed as an assistant + # tool-call message. `to_tool_call` accepts both shapes on inbound, + # so the round-trip is preserved. payload: dict[str, Any] = { "type": "function", - "function": {"name": self.name, "arguments": self.args}, + "function": {"name": self.name, "arguments": json.dumps(self.args)}, } if self.id is not None: payload["id"] = self.id diff --git a/dspy/utils/inspect_history.py b/dspy/utils/inspect_history.py index e2522b1455..77138dc1bc 100644 --- a/dspy/utils/inspect_history.py +++ b/dspy/utils/inspect_history.py @@ -78,9 +78,12 @@ def pretty_print_history(history: list[dict[str, Any]], n: int = 1, file: TextIO print("\n", file=out) if isinstance(outputs[0], dict): - if outputs[0]["text"]: + # The Responses API can produce tool-calls-only outputs that omit + # the `text` key entirely, so use `.get` rather than direct lookup. + text = outputs[0].get("text") + if text: print(_red("Response:", use_colors=use_colors), file=out) - print(_green(outputs[0]["text"].strip(), use_colors=use_colors), file=out) + print(_green(text.strip(), use_colors=use_colors), file=out) if outputs[0].get("tool_calls"): print(_red("Tool calls:", use_colors=use_colors), file=out) diff --git a/tests/adapters/test_tool.py b/tests/adapters/test_tool.py index ff8b1c44c8..67a68c8157 100644 --- a/tests/adapters/test_tool.py +++ b/tests/adapters/test_tool.py @@ -398,7 +398,9 @@ def test_async_tool_call_in_sync_mode(): ( [{"name": "search", "args": {"query": "hello"}}], { - "tool_calls": [{"type": "function", "function": {"name": "search", "arguments": {"query": "hello"}}}], + "tool_calls": [ + {"type": "function", "function": {"name": "search", "arguments": '{"query": "hello"}'}} + ], }, ), ( @@ -408,10 +410,10 @@ def test_async_tool_call_in_sync_mode(): ], { "tool_calls": [ - {"type": "function", "function": {"name": "search", "arguments": {"query": "hello"}}}, + {"type": "function", "function": {"name": "search", "arguments": '{"query": "hello"}'}}, { "type": "function", - "function": {"name": "translate", "arguments": {"text": "world", "lang": "fr"}}, + "function": {"name": "translate", "arguments": '{"text": "world", "lang": "fr"}'}, }, ], }, @@ -419,7 +421,7 @@ def test_async_tool_call_in_sync_mode(): ( [{"name": "get_time", "args": {}}], { - "tool_calls": [{"type": "function", "function": {"name": "get_time", "arguments": {}}}], + "tool_calls": [{"type": "function", "function": {"name": "get_time", "arguments": "{}"}}], }, ), ] @@ -758,6 +760,24 @@ def test_toolcall_format_omits_id_when_absent(): assert "id" not in payload +def test_toolcall_format_arguments_is_json_string_for_openai_assistant_message(): + """OpenAI Chat Completions requires `function.arguments` to be a + JSON-encoded **string** when this payload is replayed as an assistant + tool-call message (i.e. dropped into `{"role":"assistant","tool_calls":[...]}` ). + Serializing `arguments` as a Python dict makes the API reject the request. + """ + import json as _json + + payload = ToolCalls.ToolCall(name="search", args={"q": "hello", "n": 3}, id="call_1").format() + assert isinstance(payload["function"]["arguments"], str) + assert _json.loads(payload["function"]["arguments"]) == {"q": "hello", "n": 3} + + +def test_toolcall_format_empty_args_is_json_object_string(): + payload = ToolCalls.ToolCall(name="ping", args={}).format() + assert payload["function"]["arguments"] == "{}" + + def test_tool_format_chat_completions_shape(): tool = dspy.Tool(lambda city: city, name="get_weather", desc="weather") payload = tool.format_as_litellm_function_call(model_type="chat") diff --git a/tests/clients/test_inspect_global_history.py b/tests/clients/test_inspect_global_history.py index cd9a42b703..eb9c5c1460 100644 --- a/tests/clients/test_inspect_global_history.py +++ b/tests/clients/test_inspect_global_history.py @@ -75,3 +75,27 @@ def test_inspect_history_n_larger_than_history(capsys): dspy.inspect_history(n=5) history = GLOBAL_HISTORY assert len(history) == 2 # Should return all available entries + + +def test_pretty_print_history_handles_tool_calls_only_output(capsys): + """Responses API can return an output with `tool_calls` but no `text` key. + `pretty_print_history` must not KeyError on those entries.""" + from dspy.utils.inspect_history import pretty_print_history + + entry = { + "messages": [{"role": "user", "content": "What's the weather in Paris?"}], + "outputs": [ + { + "tool_calls": [ + dspy.ToolCalls.ToolCall(name="get_weather", args={"city": "Paris"}, id="call_1") + ] + } + ], + "timestamp": "now", + } + + pretty_print_history([entry], n=1) + out, _ = capsys.readouterr() + assert "Tool calls:" in out + assert "get_weather" in out + assert "Response:" not in out # no text key => no Response section From 835cfbc29736d6c4c85aed55e3e4beaa244c3725 Mon Sep 17 00:00:00 2001 From: isaacbmiller Date: Tue, 12 May 2026 18:24:54 -0400 Subject: [PATCH 12/14] fix(base_lm): normalize Responses API output shape so `text` is always present MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix in inspect_history.py and adapters/base.py used `.get("text")` to paper over a real inconsistency: `_process_completion` always set `output["text"]` (possibly to None) while `_process_response` only added the key when there was message content. Downstream code shouldn't see that variance — the whole point of the LM-layer boundary is to hand off one canonical shape. Fix the source: `_process_response` now always sets `result["text"]`, to None when the Responses API returned no message blocks (e.g. tool-calls-only outputs). With that contract in place, the defensive `.get("text")` calls in adapters/base.py, two_step_adapter.py, and inspect_history.py are reverted to direct key access. Restored `ToolCall` to extend `Type` (it was downgraded to `pydantic.BaseModel` when this PR moved it through the clients layer; that move is no longer in effect and the downgrade has no justification). Also propagates the new shape into test expectations: - test_lm.py::test_responses_api_tool_calls - test_inspect_global_history.py::test_pretty_print_history_handles_tool_calls_only_output Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- dspy/adapters/base.py | 5 +---- dspy/adapters/two_step_adapter.py | 2 +- dspy/adapters/types/tool.py | 2 +- dspy/clients/base_lm.py | 9 ++++++--- dspy/utils/inspect_history.py | 7 ++----- tests/clients/test_inspect_global_history.py | 11 +++++++---- tests/clients/test_lm.py | 9 ++++++--- 7 files changed, 24 insertions(+), 21 deletions(-) diff --git a/dspy/adapters/base.py b/dspy/adapters/base.py index 723388d5ee..25b38e9840 100644 --- a/dspy/adapters/base.py +++ b/dspy/adapters/base.py @@ -123,10 +123,7 @@ def _call_postprocess( text = output if isinstance(output, dict): - # The Responses API path can produce tool-calls-only outputs - # with no `text` key; the Chat Completions path always sets `text` - # (possibly to None). Use .get() so both surface uniformly. - text = output.get("text") + text = output["text"] output_logprobs = output.get("logprobs") tool_calls = output.get("tool_calls") diff --git a/dspy/adapters/two_step_adapter.py b/dspy/adapters/two_step_adapter.py index c13d0bd394..f2b9408e4c 100644 --- a/dspy/adapters/two_step_adapter.py +++ b/dspy/adapters/two_step_adapter.py @@ -124,7 +124,7 @@ async def acall( text = output if isinstance(output, dict): - text = output.get("text") + text = output["text"] output_logprobs = output.get("logprobs") tool_calls = output.get("tool_calls") diff --git a/dspy/adapters/types/tool.py b/dspy/adapters/types/tool.py index 32551654a6..da07153d1b 100644 --- a/dspy/adapters/types/tool.py +++ b/dspy/adapters/types/tool.py @@ -270,7 +270,7 @@ def __str__(self): class ToolCalls(Type): - class ToolCall(pydantic.BaseModel): + class ToolCall(Type): name: str args: dict[str, Any] id: str | None = None diff --git a/dspy/clients/base_lm.py b/dspy/clients/base_lm.py index 5623f31ba2..b251ec6e81 100644 --- a/dspy/clients/base_lm.py +++ b/dspy/clients/base_lm.py @@ -334,9 +334,12 @@ def _process_response(self, response): for summary_item in output_item.summary: reasoning_contents.append(summary_item.text) - result = {} - if len(text_outputs) > 0: - result["text"] = "".join(text_outputs) + # Normalize the output shape to match `_process_completion`: `text` is + # always present (None when the response carried no message content), + # and the optional keys (`tool_calls`, `reasoning_content`) are only + # included when non-empty. This lets downstream code use direct key + # access on `text` without special-casing the Responses API. + result: dict[str, Any] = {"text": "".join(text_outputs) if text_outputs else None} if len(tool_calls) > 0: result["tool_calls"] = tool_calls if len(reasoning_contents) > 0: diff --git a/dspy/utils/inspect_history.py b/dspy/utils/inspect_history.py index 77138dc1bc..e2522b1455 100644 --- a/dspy/utils/inspect_history.py +++ b/dspy/utils/inspect_history.py @@ -78,12 +78,9 @@ def pretty_print_history(history: list[dict[str, Any]], n: int = 1, file: TextIO print("\n", file=out) if isinstance(outputs[0], dict): - # The Responses API can produce tool-calls-only outputs that omit - # the `text` key entirely, so use `.get` rather than direct lookup. - text = outputs[0].get("text") - if text: + if outputs[0]["text"]: print(_red("Response:", use_colors=use_colors), file=out) - print(_green(text.strip(), use_colors=use_colors), file=out) + print(_green(outputs[0]["text"].strip(), use_colors=use_colors), file=out) if outputs[0].get("tool_calls"): print(_red("Tool calls:", use_colors=use_colors), file=out) diff --git a/tests/clients/test_inspect_global_history.py b/tests/clients/test_inspect_global_history.py index eb9c5c1460..0512d01a10 100644 --- a/tests/clients/test_inspect_global_history.py +++ b/tests/clients/test_inspect_global_history.py @@ -78,17 +78,20 @@ def test_inspect_history_n_larger_than_history(capsys): def test_pretty_print_history_handles_tool_calls_only_output(capsys): - """Responses API can return an output with `tool_calls` but no `text` key. - `pretty_print_history` must not KeyError on those entries.""" + """LM outputs that carry only tool calls have `text` set to None + (the normalized shape produced by both `_process_completion` and + `_process_response`). `pretty_print_history` must skip the Response + section in that case and still print the tool calls.""" from dspy.utils.inspect_history import pretty_print_history entry = { "messages": [{"role": "user", "content": "What's the weather in Paris?"}], "outputs": [ { + "text": None, "tool_calls": [ dspy.ToolCalls.ToolCall(name="get_weather", args={"city": "Paris"}, id="call_1") - ] + ], } ], "timestamp": "now", @@ -98,4 +101,4 @@ def test_pretty_print_history_handles_tool_calls_only_output(capsys): out, _ = capsys.readouterr() assert "Tool calls:" in out assert "get_weather" in out - assert "Response:" not in out # no text key => no Response section + assert "Response:" not in out # text is None => no Response section diff --git a/tests/clients/test_lm.py b/tests/clients/test_lm.py index 3b8ff389d5..a8cecb4231 100644 --- a/tests/clients/test_lm.py +++ b/tests/clients/test_lm.py @@ -619,13 +619,16 @@ def test_responses_api_tool_calls(litellm_test_server): "id": "call_1", } # `lm()` returns canonical `ToolCalls.ToolCall` objects regardless of which - # wire shape (Chat Completions vs Responses API) the provider used. The - # boundary that does this normalization is `dspy.adapters.types.tool.to_tool_call`. + # wire shape (Chat Completions vs Responses API) the provider used, and the + # output dict is normalized so `text` is always present (None when the + # response carried no message content). Boundary lives in + # `dspy.adapters.types.tool.to_tool_call`. expected_response = [ { + "text": None, "tool_calls": [ dspy.ToolCalls.ToolCall(name="get_weather", args={"city": "Paris"}, id="call_1") - ] + ], } ] From af60733f17ef922a3d945c7b6c17d99dddc60b35 Mon Sep 17 00:00:00 2001 From: isaacbmiller Date: Wed, 13 May 2026 10:39:03 -0400 Subject: [PATCH 13/14] refactor(tool-call): make the boundary symmetric and explicit on both sides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tightens three conceptual gaps the previous shape left open: 1. Outbound symmetry. Added `ToolCalls.ToolCall.format_as_litellm_tool_call(model_type)` so a canonical tool call can be re-serialized to either the Chat Completions assistant wire shape (`type: function, function: {...}, id`) or the Responses API shape (`type: function_call, name, arguments, call_id`). Previously `format()` was hard-coded to chat — replaying a tool call into the Responses API in a multi-turn loop was impossible through the boundary. `format()` is kept as a thin alias that delegates to the chat dialect so this type still plugs into the existing `Type.serialize_model` machinery for prompt rendering. 2. Inbound declaration replaces positional sniffing. `to_tool_call` now requires `model_type`. Each caller passes what they know (`_process_completion` is always "chat"; `_process_response` is always "responses"). Wrong-dialect payloads raise a precise error instead of silently falling through. `_to_tool_call_chat` and `_to_tool_call_responses` split the two paths; only the MockValSer-pydantic-bug fallback is preserved (with its own scoped attribute-access branch per dialect). 3. Naming. Renamed `Tool.format_as_litellm_function_call` → `format_as_litellm_tool_definition`. The method serializes a tool *definition* for the `tools=` array, not a function *call*; the new name pairs cleanly with `ToolCall.format_as_litellm_tool_call` so the inbound/outbound boundary now reads as a matched set. Six new symmetry tests in test_tool.py; existing callers in `adapters/base.py` and `clients/base_lm.py` updated; doc reference updated. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- docs/docs/api/primitives/Tool.md | 2 +- dspy/adapters/base.py | 2 +- dspy/adapters/types/tool.py | 150 ++++++++++++++++++++++--------- dspy/clients/base_lm.py | 4 +- tests/adapters/test_tool.py | 130 +++++++++++++++++++-------- 5 files changed, 207 insertions(+), 81 deletions(-) diff --git a/docs/docs/api/primitives/Tool.md b/docs/docs/api/primitives/Tool.md index 45f447e50c..df872ecc10 100644 --- a/docs/docs/api/primitives/Tool.md +++ b/docs/docs/api/primitives/Tool.md @@ -11,7 +11,7 @@ - description - extract_custom_type_from_annotation - format - - format_as_litellm_function_call + - format_as_litellm_tool_definition - from_langchain - from_mcp_tool - is_streamable diff --git a/dspy/adapters/base.py b/dspy/adapters/base.py index 25b38e9840..987c34907f 100644 --- a/dspy/adapters/base.py +++ b/dspy/adapters/base.py @@ -83,7 +83,7 @@ def _call_preprocess( tools = inputs[tool_call_input_field_name] tools = tools if isinstance(tools, list) else [tools] - lm_tools = [tool.format_as_litellm_function_call(model_type=lm.model_type) for tool in tools] + lm_tools = [tool.format_as_litellm_tool_definition(model_type=lm.model_type) for tool in tools] lm_kwargs["tools"] = lm_tools diff --git a/dspy/adapters/types/tool.py b/dspy/adapters/types/tool.py index da07153d1b..e870c4ca9b 100644 --- a/dspy/adapters/types/tool.py +++ b/dspy/adapters/types/tool.py @@ -150,14 +150,15 @@ def _validate_and_parse_args(self, **kwargs): def format(self): return str(self) - # TODO(MaximeRivest): Change to be to_LMToolCallPart - def format_as_litellm_function_call(self, model_type: str = "chat") -> dict[str, Any]: - """Single outbound boundary for serializing this tool to a LiteLLM payload. - - Chat / text completions expect the OpenAI Chat Completions wrapper - (``{type: "function", function: {name, description, parameters}}``); - the Responses API expects the flattened shape - (``{type: "function", name, description, parameters}``). + # TODO(MaximeRivest): Change to be to_LMToolDefinitionPart + def format_as_litellm_tool_definition(self, model_type: str) -> dict[str, Any]: + """Outbound boundary: serialize this tool *definition* for the LiteLLM + ``tools=`` request payload. + + ``model_type="chat"`` — OpenAI Chat Completions wrapper: + ``{type: "function", function: {name, description, parameters}}``. + ``model_type="responses"`` — OpenAI Responses API flattened shape: + ``{type: "function", name, description, parameters}``. """ fn = { "name": self.name, @@ -170,7 +171,9 @@ def format_as_litellm_function_call(self, model_type: str = "chat") -> dict[str, } if model_type == "responses": return {"type": "function", **fn} - return {"type": "function", "function": fn} + if model_type == "chat": + return {"type": "function", "function": fn} + raise ValueError(f"Unknown model_type: {model_type!r}. Expected 'chat' or 'responses'.") def _run_async_in_sync(self, coroutine): try: @@ -275,18 +278,45 @@ class ToolCall(Type): args: dict[str, Any] id: str | None = None + # TODO(MaximeRivest): Change to be to_LMToolCallPart + def format_as_litellm_tool_call(self, model_type: str) -> dict[str, Any]: + """Outbound boundary: serialize this tool *call* for replay in a + LiteLLM assistant message (multi-turn tool loops). + + Symmetric with ``Tool.format_as_litellm_tool_definition``: same + ``model_type`` parameter, same two dialects. ``function.arguments`` + is JSON-encoded as required by both APIs. + + ``model_type="chat"`` — OpenAI Chat Completions: + ``{type:"function", function:{name, arguments}, id?}``. + ``model_type="responses"`` — OpenAI Responses API: + ``{type:"function_call", name, arguments, call_id?}``. + """ + args_str = json.dumps(self.args) + if model_type == "responses": + payload: dict[str, Any] = { + "type": "function_call", + "name": self.name, + "arguments": args_str, + } + if self.id is not None: + payload["call_id"] = self.id + return payload + if model_type == "chat": + payload = { + "type": "function", + "function": {"name": self.name, "arguments": args_str}, + } + if self.id is not None: + payload["id"] = self.id + return payload + raise ValueError(f"Unknown model_type: {model_type!r}. Expected 'chat' or 'responses'.") + def format(self) -> dict[str, Any]: - # OpenAI Chat Completions requires `function.arguments` to be a - # JSON-encoded string when this payload is replayed as an assistant - # tool-call message. `to_tool_call` accepts both shapes on inbound, - # so the round-trip is preserved. - payload: dict[str, Any] = { - "type": "function", - "function": {"name": self.name, "arguments": json.dumps(self.args)}, - } - if self.id is not None: - payload["id"] = self.id - return payload + # `Type` contract — used by `Type.serialize_model` to embed this + # tool call inline in prompt text. Defaults to the Chat Completions + # dialect; switch dialects via `format_as_litellm_tool_call`. + return self.format_as_litellm_tool_call("chat") def execute(self, functions: "dict[str, Any] | list[Tool] | None" = None) -> Any: """Execute this individual tool call and return its result. @@ -364,21 +394,62 @@ def coerce(items): raise ValueError(f"Invalid value for `dspy.ToolCalls`: {data!r}") -# TODO(MaximeRivest): Change this interface to be from_LMToolResultPart. -def to_tool_call(item: Any) -> ToolCalls.ToolCall: - """Normalize a LiteLLM tool-call into a canonical ``ToolCalls.ToolCall``. +# TODO(MaximeRivest): Change to be from_LMToolResultPart. +def to_tool_call(item: Any, model_type: str) -> ToolCalls.ToolCall: + """Inbound boundary: normalize one LiteLLM tool-call into a canonical + ``ToolCalls.ToolCall``. + + ``model_type`` is required — it declares which provider dialect ``item`` + came from. Callers always know this (``base_lm._process_completion`` is + always ``"chat"``; ``_process_response`` is always ``"responses"``), so + threading it through the boundary keeps dispatch explicit instead of + sniffing wire-shape keys. - Single inbound boundary for wire-shape coercion. Falls back to attribute - access when ``model_dump()`` raises ``TypeError`` because of the - MockValSer/SchemaSerializer bug (pydantic#7713, litellm#9345). + Each branch tolerates two surface shapes: a plain ``dict`` and a pydantic + object exposing ``model_dump``. When ``model_dump`` raises ``TypeError`` + (pydantic#7713 / litellm#9345 — the MockValSer/SchemaSerializer bug), we + fall back to attribute access for the same fields. That fallback is the + one and only fallback in this boundary; everything else is a hard error. """ + if model_type == "chat": + return _to_tool_call_chat(item) + if model_type == "responses": + return _to_tool_call_responses(item) + raise ValueError(f"Unknown model_type: {model_type!r}. Expected 'chat' or 'responses'.") + + +def _to_tool_call_chat(item: Any) -> "ToolCalls.ToolCall": if not isinstance(item, dict) and hasattr(item, "model_dump"): try: item = item.model_dump() except TypeError: fn = getattr(item, "function", None) - if fn is not None: - return ToolCalls.ToolCall(name=fn.name, args=_parse_args(fn.arguments), id=getattr(item, "id", None)) + if fn is None: + raise + return ToolCalls.ToolCall( + name=fn.name, + args=_parse_args(fn.arguments), + id=getattr(item, "id", None), + ) + + if not isinstance(item, dict): + raise TypeError(f"Cannot normalize Chat Completions tool call from {type(item).__name__}: {item!r}") + if item.get("type") != "function" or not isinstance(item.get("function"), dict): + raise ValueError(f"Expected Chat Completions tool-call shape, got: {item!r}") + + fn = item["function"] + return ToolCalls.ToolCall( + name=fn["name"], + args=_parse_args(fn.get("arguments")), + id=item.get("id"), + ) + + +def _to_tool_call_responses(item: Any) -> "ToolCalls.ToolCall": + if not isinstance(item, dict) and hasattr(item, "model_dump"): + try: + item = item.model_dump() + except TypeError: if getattr(item, "name", None) is None: raise return ToolCalls.ToolCall( @@ -388,20 +459,15 @@ def to_tool_call(item: Any) -> ToolCalls.ToolCall: ) if not isinstance(item, dict): - raise TypeError(f"Cannot normalize tool call from {type(item).__name__}: {item!r}") - - if item.get("type") == "function" and isinstance(item.get("function"), dict): - fn = item["function"] - return ToolCalls.ToolCall(name=fn["name"], args=_parse_args(fn.get("arguments")), id=item.get("id")) - - if item.get("type") == "function_call" and item.get("name"): - return ToolCalls.ToolCall( - name=item["name"], - args=_parse_args(item.get("arguments")), - id=item.get("call_id") or item.get("id"), - ) - - raise ValueError(f"Unknown tool-call shape: {item!r}") + raise TypeError(f"Cannot normalize Responses API tool call from {type(item).__name__}: {item!r}") + if item.get("type") != "function_call" or not item.get("name"): + raise ValueError(f"Expected Responses API function_call shape, got: {item!r}") + + return ToolCalls.ToolCall( + name=item["name"], + args=_parse_args(item.get("arguments")), + id=item.get("call_id") or item.get("id"), + ) def _parse_args(args: Any) -> dict[str, Any]: diff --git a/dspy/clients/base_lm.py b/dspy/clients/base_lm.py index b251ec6e81..df4fe07c4d 100644 --- a/dspy/clients/base_lm.py +++ b/dspy/clients/base_lm.py @@ -270,7 +270,7 @@ def _process_completion(self, response, merged_kwargs): # TODO(MaximeRivest): Change this interface to use ToolPart. # Lazy import avoids `base_lm → adapters/tool → base_type → base_lm`. from dspy.adapters.types.tool import to_tool_call - output["tool_calls"] = [to_tool_call(tc) for tc in c.message.tool_calls] + output["tool_calls"] = [to_tool_call(tc, model_type="chat") for tc in c.message.tool_calls] # Extract citations from LiteLLM response if available citations = self._extract_citations_from_response(c) @@ -325,7 +325,7 @@ def _process_response(self, response): # TODO(MaximeRivest): Change this interface to use ToolPart. # Lazy import avoids `base_lm → adapters/tool → base_type → base_lm`. from dspy.adapters.types.tool import to_tool_call - tool_calls.append(to_tool_call(output_item)) + tool_calls.append(to_tool_call(output_item, model_type="responses")) elif output_item_type == "reasoning": if getattr(output_item, "content", None) and len(output_item.content) > 0: for content_item in output_item.content: diff --git a/tests/adapters/test_tool.py b/tests/adapters/test_tool.py index 67a68c8157..194aaa6123 100644 --- a/tests/adapters/test_tool.py +++ b/tests/adapters/test_tool.py @@ -611,7 +611,7 @@ def test_to_tool_call_chat_completions_dict_shape(): "type": "function", "function": {"name": "search", "arguments": '{"q":"hello"}'}, } - tc = to_tool_call(item) + tc = to_tool_call(item, model_type="chat") assert isinstance(tc, ToolCalls.ToolCall) assert tc.name == "search" assert tc.args == {"q": "hello"} @@ -637,7 +637,7 @@ def model_dump(self): "function": {"name": self.function.name, "arguments": self.function.arguments}, } - tc = to_tool_call(CCMToolCall()) + tc = to_tool_call(CCMToolCall(), model_type="chat") assert tc.name == "search" assert tc.args == {"q": "x"} assert tc.id == "call_123" @@ -646,14 +646,14 @@ def model_dump(self): def test_to_tool_call_chat_completions_arguments_as_dict(): """Some providers (and our own round-trips) put `arguments` as a dict.""" item = {"type": "function", "function": {"name": "lookup", "arguments": {"k": "v"}}} - tc = to_tool_call(item) + tc = to_tool_call(item, model_type="chat") assert tc.args == {"k": "v"} def test_to_tool_call_chat_completions_empty_arguments_string(): """`arguments=""` should normalize to `{}`, not crash.""" item = {"type": "function", "function": {"name": "ping", "arguments": ""}} - assert to_tool_call(item).args == {} + assert to_tool_call(item, model_type="chat").args == {} def test_to_tool_call_responses_api_dict_shape(): @@ -663,7 +663,7 @@ def test_to_tool_call_responses_api_dict_shape(): "arguments": '{"q":"y"}', "call_id": "call_xyz", } - tc = to_tool_call(item) + tc = to_tool_call(item, model_type="responses") assert tc.name == "search" assert tc.args == {"q": "y"} assert tc.id == "call_xyz" @@ -684,7 +684,7 @@ def model_dump(self): "call_id": self.call_id, } - tc = to_tool_call(FunctionCallItem()) + tc = to_tool_call(FunctionCallItem(), model_type="responses") assert tc.name == "search" assert tc.args == {"q": "z"} assert tc.id == "call_99" @@ -709,31 +709,49 @@ class CachedToolCall: def model_dump(self): raise TypeError("'MockValSer' object cannot be converted to 'SchemaSerializer'") - tc = to_tool_call(CachedToolCall()) + tc = to_tool_call(CachedToolCall(), model_type="chat") assert tc.name == "search" assert tc.args == {"q": "cached"} assert tc.id == "call_cached" def test_to_tool_call_mockvalser_fallback_no_recoverable_attrs_raises(): - """If model_dump fails AND there's no `function` or `name` attribute, - re-raise the original MockValSer TypeError instead of returning garbage.""" + """If model_dump fails AND there's no `function` (chat) or `name` (responses) + attribute, re-raise the original MockValSer TypeError instead of returning garbage.""" class Unsalvageable: def model_dump(self): raise TypeError("'MockValSer' object cannot be converted to 'SchemaSerializer'") with pytest.raises(TypeError, match="MockValSer"): - to_tool_call(Unsalvageable()) + to_tool_call(Unsalvageable(), model_type="chat") + with pytest.raises(TypeError, match="MockValSer"): + to_tool_call(Unsalvageable(), model_type="responses") + + +def test_to_tool_call_chat_rejects_responses_shape(): + """Declaring model_type='chat' but passing a Responses API payload must + raise — no silent fall-through to the other dialect.""" + item = {"type": "function_call", "name": "search", "arguments": "{}", "call_id": "c1"} + with pytest.raises(ValueError, match="Expected Chat Completions tool-call shape"): + to_tool_call(item, model_type="chat") -def test_to_tool_call_unknown_dict_shape_raises_with_payload(): - with pytest.raises(ValueError, match="Unknown tool-call shape"): - to_tool_call({"unrelated": "data"}) +def test_to_tool_call_responses_rejects_chat_shape(): + item = {"type": "function", "function": {"name": "search", "arguments": "{}"}} + with pytest.raises(ValueError, match="Expected Responses API function_call shape"): + to_tool_call(item, model_type="responses") + + +def test_to_tool_call_rejects_unknown_model_type(): + with pytest.raises(ValueError, match="Unknown model_type"): + to_tool_call({"type": "function", "function": {"name": "x", "arguments": "{}"}}, model_type="gemini") def test_to_tool_call_non_dict_non_pydantic_raises_with_type_info(): - with pytest.raises(TypeError, match="Cannot normalize tool call from int"): - to_tool_call(42) + with pytest.raises(TypeError, match="Cannot normalize Chat Completions tool call from int"): + to_tool_call(42, model_type="chat") + with pytest.raises(TypeError, match="Cannot normalize Responses API tool call from int"): + to_tool_call(42, model_type="responses") def test_toolcall_id_field_optional(): @@ -747,40 +765,81 @@ def test_toolcall_id_round_trips(): assert tc.id == "call_zzz" -def test_toolcall_format_preserves_id_for_round_trip(): +def test_toolcall_format_preserves_id_chat_round_trip(): + original = ToolCalls.ToolCall(name="search", args={"q": "hello"}, id="call_xyz") + restored = to_tool_call(original.format_as_litellm_tool_call("chat"), model_type="chat") + assert restored == original + + +def test_toolcall_format_preserves_id_responses_round_trip(): + """Outbound symmetry: a tool call serialized for the Responses API must + round-trip back through `to_tool_call(..., model_type='responses')`.""" original = ToolCalls.ToolCall(name="search", args={"q": "hello"}, id="call_xyz") - restored = to_tool_call(original.format()) + restored = to_tool_call(original.format_as_litellm_tool_call("responses"), model_type="responses") assert restored == original +def test_toolcall_format_default_alias_uses_chat_dialect(): + """`format()` (the Type contract) is a thin alias for the Chat Completions + dialect — kept so this type still plugs into `Type.serialize_model` for + prompt rendering.""" + tc = ToolCalls.ToolCall(name="search", args={"q": "x"}, id="c1") + assert tc.format() == tc.format_as_litellm_tool_call("chat") + + def test_toolcall_format_omits_id_when_absent(): - """A ToolCall constructed without an id should serialize without an `id` key - so we don't fabricate one on the wire.""" - payload = ToolCalls.ToolCall(name="search", args={"q": "x"}).format() - assert "id" not in payload + """A ToolCall constructed without an id should serialize without an `id` + (chat) or `call_id` (responses) key so we don't fabricate one on the wire.""" + chat = ToolCalls.ToolCall(name="search", args={"q": "x"}).format_as_litellm_tool_call("chat") + assert "id" not in chat + responses = ToolCalls.ToolCall(name="search", args={"q": "x"}).format_as_litellm_tool_call("responses") + assert "call_id" not in responses def test_toolcall_format_arguments_is_json_string_for_openai_assistant_message(): """OpenAI Chat Completions requires `function.arguments` to be a JSON-encoded **string** when this payload is replayed as an assistant - tool-call message (i.e. dropped into `{"role":"assistant","tool_calls":[...]}` ). - Serializing `arguments` as a Python dict makes the API reject the request. - """ + tool-call message. Same for the Responses API top-level `arguments`.""" import json as _json - payload = ToolCalls.ToolCall(name="search", args={"q": "hello", "n": 3}, id="call_1").format() - assert isinstance(payload["function"]["arguments"], str) - assert _json.loads(payload["function"]["arguments"]) == {"q": "hello", "n": 3} + chat = ToolCalls.ToolCall(name="search", args={"q": "hello", "n": 3}, id="c1").format_as_litellm_tool_call("chat") + assert isinstance(chat["function"]["arguments"], str) + assert _json.loads(chat["function"]["arguments"]) == {"q": "hello", "n": 3} + + responses = ToolCalls.ToolCall(name="search", args={"q": "hello", "n": 3}, id="c1").format_as_litellm_tool_call( + "responses" + ) + assert isinstance(responses["arguments"], str) + assert _json.loads(responses["arguments"]) == {"q": "hello", "n": 3} + + +def test_toolcall_format_responses_uses_call_id_not_id(): + """The Responses API field is `call_id`, not `id`.""" + payload = ToolCalls.ToolCall(name="x", args={}, id="call_1").format_as_litellm_tool_call("responses") + assert payload["call_id"] == "call_1" + assert "id" not in payload + assert payload["type"] == "function_call" def test_toolcall_format_empty_args_is_json_object_string(): - payload = ToolCalls.ToolCall(name="ping", args={}).format() - assert payload["function"]["arguments"] == "{}" + assert ( + ToolCalls.ToolCall(name="ping", args={}).format_as_litellm_tool_call("chat")["function"]["arguments"] + == "{}" + ) + assert ( + ToolCalls.ToolCall(name="ping", args={}).format_as_litellm_tool_call("responses")["arguments"] + == "{}" + ) + + +def test_toolcall_format_rejects_unknown_model_type(): + with pytest.raises(ValueError, match="Unknown model_type"): + ToolCalls.ToolCall(name="x", args={}).format_as_litellm_tool_call("gemini") -def test_tool_format_chat_completions_shape(): +def test_tool_definition_chat_completions_shape(): tool = dspy.Tool(lambda city: city, name="get_weather", desc="weather") - payload = tool.format_as_litellm_function_call(model_type="chat") + payload = tool.format_as_litellm_tool_definition(model_type="chat") assert payload["type"] == "function" assert "function" in payload assert payload["function"]["name"] == "get_weather" @@ -788,9 +847,9 @@ def test_tool_format_chat_completions_shape(): assert "parameters" in payload["function"] -def test_tool_format_responses_api_shape(): +def test_tool_definition_responses_api_shape(): tool = dspy.Tool(lambda city: city, name="get_weather", desc="weather") - payload = tool.format_as_litellm_function_call(model_type="responses") + payload = tool.format_as_litellm_tool_definition(model_type="responses") assert payload["type"] == "function" # Responses API flattens: name/description/parameters at top level, no `function` wrapper. assert "function" not in payload @@ -799,6 +858,7 @@ def test_tool_format_responses_api_shape(): assert "parameters" in payload -def test_tool_format_default_is_chat(): +def test_tool_definition_rejects_unknown_model_type(): tool = dspy.Tool(lambda city: city, name="x", desc="d") - assert tool.format_as_litellm_function_call() == tool.format_as_litellm_function_call(model_type="chat") + with pytest.raises(ValueError, match="Unknown model_type"): + tool.format_as_litellm_tool_definition(model_type="gemini") From d2b8e4e6e4ec3ff69f7a7fbe3d6b2f5fe9564586 Mon Sep 17 00:00:00 2001 From: isaacbmiller Date: Wed, 13 May 2026 10:57:01 -0400 Subject: [PATCH 14/14] docs(tool-call): document the wire-shape contract on all three boundary methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wire side stays `dict[str, Any]` / `str` — no type narrowing in the signatures — but the contract is now spelled out on each boundary method: Tool.format_as_litellm_tool_definition (outbound, definitions) ToolCalls.ToolCall.format_as_litellm_tool_call (outbound, calls) to_tool_call (inbound, calls) Each docstring lists the exact valid model_type values, the precise key sets for each dialect, the JSON-string requirement on `arguments`, which fields are conditionally emitted, and what raises. The three methods cross-reference each other so the dialect set stays discoverable in one place if it ever grows. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- dspy/adapters/types/tool.py | 111 +++++++++++++++++++++++++++++------- 1 file changed, 89 insertions(+), 22 deletions(-) diff --git a/dspy/adapters/types/tool.py b/dspy/adapters/types/tool.py index e870c4ca9b..98aef53338 100644 --- a/dspy/adapters/types/tool.py +++ b/dspy/adapters/types/tool.py @@ -155,10 +155,37 @@ def format_as_litellm_tool_definition(self, model_type: str) -> dict[str, Any]: """Outbound boundary: serialize this tool *definition* for the LiteLLM ``tools=`` request payload. - ``model_type="chat"`` — OpenAI Chat Completions wrapper: - ``{type: "function", function: {name, description, parameters}}``. - ``model_type="responses"`` — OpenAI Responses API flattened shape: - ``{type: "function", name, description, parameters}``. + Contract: + + * ``model_type`` is required. Valid values are exactly ``"chat"`` and + ``"responses"``. Any other value raises ``ValueError``. + * Returns a plain ``dict``. Its concrete shape is determined entirely + by ``model_type``: + + ``model_type="chat"`` — OpenAI Chat Completions wrapper:: + + { + "type": "function", + "function": { + "name": , + "description": , + "parameters": , + }, + } + + ``model_type="responses"`` — OpenAI Responses API flattened shape:: + + { + "type": "function", + "name": , + "description": , + "parameters": , + } + + These two dialects are the only ones the boundary speaks; this is the + same set accepted by ``ToolCalls.ToolCall.format_as_litellm_tool_call`` + and ``to_tool_call``. Keep all three in sync if a new dialect is + ever added. """ fn = { "name": self.name, @@ -284,13 +311,39 @@ def format_as_litellm_tool_call(self, model_type: str) -> dict[str, Any]: LiteLLM assistant message (multi-turn tool loops). Symmetric with ``Tool.format_as_litellm_tool_definition``: same - ``model_type`` parameter, same two dialects. ``function.arguments`` - is JSON-encoded as required by both APIs. - - ``model_type="chat"`` — OpenAI Chat Completions: - ``{type:"function", function:{name, arguments}, id?}``. - ``model_type="responses"`` — OpenAI Responses API: - ``{type:"function_call", name, arguments, call_id?}``. + two-dialect contract, same required ``model_type`` argument. + + Contract: + + * ``model_type`` is required and must be ``"chat"`` or + ``"responses"`` (anything else raises ``ValueError``). + * ``arguments`` is always emitted as a **JSON-encoded string**, as + required by both OpenAI APIs when this payload is dropped into + an ``assistant.tool_calls[...]`` entry. (Sending a Python dict + instead causes a 400 at request time.) + * The optional ``id`` field is included only when ``self.id`` is + set, and is named per the dialect: ``id`` for Chat Completions, + ``call_id`` for the Responses API. + + Returned shapes:: + + model_type="chat": + { + "type": "function", + "function": {"name": , "arguments": }, + "id": , # only when self.id is set + } + + model_type="responses": + { + "type": "function_call", + "name": , + "arguments": , + "call_id": , # only when self.id is set + } + + Round-trip: ``to_tool_call(tc.format_as_litellm_tool_call(d), d)`` + equals ``tc`` for any supported ``d``. """ args_str = json.dumps(self.args) if model_type == "responses": @@ -399,17 +452,31 @@ def to_tool_call(item: Any, model_type: str) -> ToolCalls.ToolCall: """Inbound boundary: normalize one LiteLLM tool-call into a canonical ``ToolCalls.ToolCall``. - ``model_type`` is required — it declares which provider dialect ``item`` - came from. Callers always know this (``base_lm._process_completion`` is - always ``"chat"``; ``_process_response`` is always ``"responses"``), so - threading it through the boundary keeps dispatch explicit instead of - sniffing wire-shape keys. - - Each branch tolerates two surface shapes: a plain ``dict`` and a pydantic - object exposing ``model_dump``. When ``model_dump`` raises ``TypeError`` - (pydantic#7713 / litellm#9345 — the MockValSer/SchemaSerializer bug), we - fall back to attribute access for the same fields. That fallback is the - one and only fallback in this boundary; everything else is a hard error. + Contract: + + * ``model_type`` is required and must be ``"chat"`` or ``"responses"`` — + the same two dialects served by ``Tool.format_as_litellm_tool_definition`` + and ``ToolCalls.ToolCall.format_as_litellm_tool_call``. Any other value + raises ``ValueError``. Callers always know this: + ``base_lm._process_completion`` is always ``"chat"`` and + ``_process_response`` is always ``"responses"``. + * ``item`` may be either a ``dict`` in the dialect's wire shape (see + ``format_as_litellm_tool_call`` for the exact key sets) or a pydantic + object exposing ``model_dump`` that returns such a dict. Anything else + raises ``TypeError``. + * Wrong-dialect payloads (e.g. a Responses-API shape passed with + ``model_type="chat"``) raise ``ValueError`` with a precise message + instead of silently falling through to the other branch. + * Returns a fully-populated ``ToolCalls.ToolCall``. ``args`` is always a + ``dict`` (parsed from JSON when the wire side supplies a string); + ``id`` reflects ``id`` (chat) or ``call_id`` (responses) and is + ``None`` when the provider omitted it. + + Fallback policy: when ``item.model_dump()`` raises ``TypeError`` because + of the MockValSer/SchemaSerializer bug (pydantic#7713 / litellm#9345), + we reach through to attribute access for the *same* fields the dict path + would have read. This is the only fallback in this boundary; everything + else is a hard error. """ if model_type == "chat": return _to_tool_call_chat(item)