Repository: stanfordnlp/dspy
Pull request #8509 (2025-07-08 20:19:52)

Provide a `dspy.syncify` so that users can run optimizer on async dspy programs
There two modes:

- in-place mode: return the same program instance, but patch the `forward` method. This is recommended, but may not work if users have a different sync forward already implemented, which is rare but yet possible.
- Wrapper mode: return a new DSPy module that wraps the original module. This would always work, but change the program architecture. 




Patch:
diff --git a/dspy/__init__.py b/dspy/__init__.py
index 9113e4f5..25ba18f3 100644
--- a/dspy/__init__.py
+++ b/dspy/__init__.py
@@ -6,9 +6,10 @@ from dspy.teleprompt import *
 
 from dspy.evaluate import Evaluate  # isort: skip
 from dspy.clients import *  # isort: skip
-from dspy.adapters import Adapter, ChatAdapter, JSONAdapter, XMLAdapter, TwoStepAdapter, Image, Audio, History, BaseType, Tool, ToolCalls  # isort: skip
+from dspy.adapters import Adapter, ChatAdapter, JSONAdapter, XMLAdapter, TwoStepAdapter, Image, Audio, History, Type, Tool, ToolCalls  # isort: skip
 from dspy.utils.logging_utils import configure_dspy_loggers, disable_logging, enable_logging
 from dspy.utils.asyncify import asyncify
+from dspy.utils.syncify import syncify
 from dspy.utils.saving import load
 from dspy.streaming.streamify import streamify
 from dspy.utils.usage_tracker import track_usage
diff --git a/dspy/adapters/__init__.py b/dspy/adapters/__init__.py
index 6d342df3..c2c6d23a 100644
--- a/dspy/adapters/__init__.py
+++ b/dspy/adapters/__init__.py
@@ -2,13 +2,13 @@ from dspy.adapters.base import Adapter
 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, BaseType, History, Image, Tool, ToolCalls
+from dspy.adapters.types import Audio, History, Image, Tool, ToolCalls, Type
 from dspy.adapters.xml_adapter import XMLAdapter
 
 __all__ = [
     "Adapter",
     "ChatAdapter",
-    "BaseType",
+    "Type",
     "History",
     "Image",
     "Audio",
diff --git a/dspy/adapters/types/__init__.py b/dspy/adapters/types/__init__.py
index 68607e52..bc57d50d 100644
--- a/dspy/adapters/types/__init__.py
+++ b/dspy/adapters/types/__init__.py
@@ -1,7 +1,7 @@
 from dspy.adapters.types.audio import Audio
-from dspy.adapters.types.base_type import BaseType
+from dspy.adapters.types.base_type import Type
 from dspy.adapters.types.history import History
 from dspy.adapters.types.image import Image
 from dspy.adapters.types.tool import Tool, ToolCalls
 
-__all__ = ["History", "Image", "Audio", "BaseType", "Tool", "ToolCalls"]
+__all__ = ["History", "Image", "Audio", "Type", "Tool", "ToolCalls"]
diff --git a/dspy/adapters/types/audio.py b/dspy/adapters/types/audio.py
index b4637a06..83e04156 100644
--- a/dspy/adapters/types/audio.py
+++ b/dspy/adapters/types/audio.py
@@ -7,7 +7,7 @@ from typing import Any, Union
 import pydantic
 import requests
 
-from dspy.adapters.types.base_type import BaseType
+from dspy.adapters.types.base_type import Type
 
 try:
     import soundfile as sf
@@ -17,7 +17,7 @@ except ImportError:
     SF_AVAILABLE = False
 
 
-class Audio(BaseType):
+class Audio(Type):
     data: str
     audio_format: str
 
diff --git a/dspy/adapters/types/base_type.py b/dspy/adapters/types/base_type.py
index 4ac90970..e3536922 100644
--- a/dspy/adapters/types/base_type.py
+++ b/dspy/adapters/types/base_type.py
@@ -9,7 +9,7 @@ CUSTOM_TYPE_START_IDENTIFIER = "<<CUSTOM-TYPE-START-IDENTIFIER>>"
 CUSTOM_TYPE_END_IDENTIFIER = "<<CUSTOM-TYPE-END-IDENTIFIER>>"
 
 
-class BaseType(pydantic.BaseModel):
+class Type(pydantic.BaseModel):
     """Base class to support creating custom types for DSPy signatures.
 
     This is the parent class of DSPy custom types, e.g, dspy.Image. Subclasses must implement the `format` method to
@@ -18,7 +18,7 @@ class BaseType(pydantic.BaseModel):
     Example:
 
         ```python
-        class Image(BaseType):
+        class Image(Type):
             url: str
 
             def format(self) -> list[dict[str, Any]]:
@@ -85,7 +85,7 @@ def split_message_content_for_custom_types(messages: list[dict[str, Any]]) -> li
 
     This is implemented by finding the `<<CUSTOM-TYPE-START-IDENTIFIER>>` and `<<CUSTOM-TYPE-END-IDENTIFIER>>`
     in the user message content and splitting the content around them. The `<<CUSTOM-TYPE-START-IDENTIFIER>>`
-    and `<<CUSTOM-TYPE-END-IDENTIFIER>>` are the reserved identifiers for the custom types as in `dspy.BaseType`.
+    and `<<CUSTOM-TYPE-END-IDENTIFIER>>` are the reserved identifiers for the custom types as in `dspy.Type`.
 
     Args:
         messages: a list of messages sent to the LM. The format is the same as [OpenAI API's messages
diff --git a/dspy/adapters/types/image.py b/dspy/adapters/types/image.py
index e8b44ffe..32fb786e 100644
--- a/dspy/adapters/types/image.py
+++ b/dspy/adapters/types/image.py
@@ -8,7 +8,7 @@ from urllib.parse import urlparse
 import pydantic
 import requests
 
-from dspy.adapters.types.base_type import BaseType
+from dspy.adapters.types.base_type import Type
 
 try:
     from PIL import Image as PILImage
@@ -18,7 +18,7 @@ except ImportError:
     PIL_AVAILABLE = False
 
 
-class Image(BaseType):
+class Image(Type):
     url: str
 
     model_config = {
diff --git a/dspy/adapters/types/tool.py b/dspy/adapters/types/tool.py
index c2ca4990..8e4aeda7 100644
--- a/dspy/adapters/types/tool.py
+++ b/dspy/adapters/types/tool.py
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Any, Callable, Tuple, Type, get_origin, get_ty
 from jsonschema import ValidationError, validate
 from pydantic import BaseModel, TypeAdapter, create_model
 
-from dspy.adapters.types.base_type import BaseType
+from dspy.adapters.types.base_type import Type
 from dspy.dsp.utils.settings import settings
 from dspy.utils.callback import with_callbacks
 
@@ -16,7 +16,7 @@ if TYPE_CHECKING:
 _TYPE_MAPPING = {"string": str, "integer": int, "number": float, "boolean": bool, "array": list, "object": dict}
 
 
-class Tool(BaseType):
+class Tool(Type):
     """Tool class.
 
     This class is used to simplify the creation of tools for tool calling (function calling) in LLMs. Only supports
@@ -254,7 +254,7 @@ class Tool(BaseType):
         return f"{self.name}{desc} {arg_desc}"
 
 
-class ToolCalls(BaseType):
+class ToolCalls(Type):
     class ToolCall(BaseModel):
         name: str
         args: dict[str, Any]
@@ -303,7 +303,8 @@ class ToolCalls(BaseType):
                             "name": tool_call.name,
                             "arguments": tool_call.args,
                         },
-                    } for tool_call in self.tool_calls
+                    }
+                    for tool_call in self.tool_calls
                 ],
             }
         ]
diff --git a/dspy/adapters/utils.py b/dspy/adapters/utils.py
index 6632d325..476765da 100644
--- a/dspy/adapters/utils.py
+++ b/dspy/adapters/utils.py
@@ -10,7 +10,7 @@ import pydantic
 from pydantic import TypeAdapter
 from pydantic.fields import FieldInfo
 
-from dspy.adapters.types.base_type import BaseType
+from dspy.adapters.types.base_type import Type
 from dspy.signatures.utils import get_dspy_field_type
 
 
@@ -204,7 +204,7 @@ def get_field_description_string(fields: dict) -> str:
         field_message += f" ({get_annotation_name(v.annotation)})"
         desc = v.json_schema_extra["desc"] if v.json_schema_extra["desc"] != f"${{{k}}}" else ""
 
-        custom_types = BaseType.extract_custom_type_from_annotation(v.annotation)
+        custom_types = Type.extract_custom_type_from_annotation(v.annotation)
         for custom_type in custom_types:
             if len(custom_type.description()) > 0:
                 desc += f"\n    Type description of {get_annotation_name(custom_type)}: {custom_type.description()}"
diff --git a/dspy/teleprompt/bootstrap.py b/dspy/teleprompt/bootstrap.py
index 23495bba..d7031194 100644
--- a/dspy/teleprompt/bootstrap.py
+++ b/dspy/teleprompt/bootstrap.py
@@ -245,11 +245,12 @@ class BootstrapFewShot(Teleprompter):
 
             # Update the traces
             for name, demos in name2traces.items():
-                from datasets.fingerprint import Hasher
 
                 # If there are multiple traces for the same predictor in the sample example,
                 # sample 50/50 from the first N-1 traces or the last trace.
                 if len(demos) > 1:
+                    from datasets.fingerprint import Hasher
+
                     rng = random.Random(Hasher.hash(tuple(demos)))
                     demos = [rng.choice(demos[:-1]) if rng.random() < 0.5 else demos[-1]]
                 self.name2traces[name].extend(demos)
diff --git a/dspy/utils/__init__.py b/dspy/utils/__init__.py
index d5809b7f..0c53086f 100644
--- a/dspy/utils/__init__.py
+++ b/dspy/utils/__init__.py
@@ -7,6 +7,7 @@ from dspy.utils import exceptions
 from dspy.utils.callback import BaseCallback, with_callbacks
 from dspy.utils.dummies import DummyLM, DummyVectorizer, dummy_rm
 from dspy.utils.inspect_history import pretty_print_history
+from dspy.utils.syncify import syncify
 
 
 def download(url):
diff --git a/dspy/utils/syncify.py b/dspy/utils/syncify.py
new file mode 100644
index 00000000..01aee61c
--- /dev/null
+++ b/dspy/utils/syncify.py
@@ -0,0 +1,60 @@
+import asyncio
+from types import MethodType
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from dspy.primitives.module import Module
+
+
+def run_async(coro):
+    """Run an async coroutine from a synchronous context."""
+    try:
+        loop = asyncio.get_running_loop()
+    except RuntimeError:
+        loop = None
+
+    if loop and loop.is_running():
+        # If we're in a running event loop (e.g., Jupyter), use asyncio.create_task and run until done
+        import nest_asyncio
+
+        nest_asyncio.apply()
+        return asyncio.get_event_loop().run_until_complete(coro)
+    else:
+        return asyncio.run(coro)
+
+
+def syncify(program: "Module", in_place: bool = True) -> "Module":
+    """Convert an async DSPy module to a sync program.
+
+    There are two modes of this function:
+
+    - `in_place=True` (recommended): Modify the module in place. But this may not work if you already have a `forward`
+        method which does different things from `aforward`.
+    - `in_place=False`: Return a wrapper module. This changes the module's architecture, but it's more robust.
+
+    Args:
+        program: The async program to convert, must have an `aforward` method implemented.
+        in_place: If True, modify the module in place. Otherwise, return a wrapper module.
+
+    Returns:
+        The sync program, which has a `forward` method that can be called from a synchronous context.
+    """
+    if in_place:
+
+        def forward(self, *args, **kwargs):
+            return run_async(self.aforward(*args, **kwargs))
+
+        # Create the `forward` method in place.
+        program.forward = MethodType(forward, program)
+        return program
+    else:
+        from dspy.primitives.module import Module
+
+        class SyncWrapper(Module):
+            def __init__(self, program: "Module"):
+                self.program = program
+
+            def forward(self, *args, **kwargs):
+                return run_async(self.program.aforward(*args, **kwargs))
+
+        return SyncWrapper(program)


Test patch:
diff --git a/tests/adapters/test_base_type.py b/tests/adapters/test_base_type.py
index 1776d319..b7d30864 100644
--- a/tests/adapters/test_base_type.py
+++ b/tests/adapters/test_base_type.py
@@ -1,11 +1,10 @@
-
 import pydantic
 
 import dspy
 
 
 def test_basic_extract_custom_type_from_annotation():
-    class Event(dspy.BaseType):
+    class Event(dspy.Type):
         event_name: str
         start_date_time: str
         end_date_time: str | None
@@ -17,7 +16,7 @@ def test_basic_extract_custom_type_from_annotation():
         email: str = dspy.InputField()
         event: Event = dspy.OutputField()
 
-    assert dspy.BaseType.extract_custom_type_from_annotation(ExtractEvent.output_fields["event"].annotation) == [Event]
+    assert dspy.Type.extract_custom_type_from_annotation(ExtractEvent.output_fields["event"].annotation) == [Event]
 
     class ExtractEvents(dspy.Signature):
         """Extract all events from the email content."""
@@ -25,19 +24,17 @@ def test_basic_extract_custom_type_from_annotation():
         email: str = dspy.InputField()
         events: list[Event] = dspy.OutputField()
 
-    assert dspy.BaseType.extract_custom_type_from_annotation(ExtractEvents.output_fields["events"].annotation) == [
-        Event
-    ]
+    assert dspy.Type.extract_custom_type_from_annotation(ExtractEvents.output_fields["events"].annotation) == [Event]
 
 
 def test_extract_custom_type_from_annotation_with_nested_type():
-    class Event(dspy.BaseType):
+    class Event(dspy.Type):
         event_name: str
         start_date_time: str
         end_date_time: str | None
         location: str | None
 
-    class EventIdentifier(dspy.BaseType):
+    class EventIdentifier(dspy.Type):
         model_config = pydantic.ConfigDict(frozen=True)  # Make it hashable
         event_id: str
         event_name: str
@@ -48,7 +45,7 @@ def test_extract_custom_type_from_annotation_with_nested_type():
         email: str = dspy.InputField()
         events: list[dict[EventIdentifier, Event]] = dspy.OutputField()
 
-    assert dspy.BaseType.extract_custom_type_from_annotation(ExtractEvents.output_fields["events"].annotation) == [
+    assert dspy.Type.extract_custom_type_from_annotation(ExtractEvents.output_fields["events"].annotation) == [
         EventIdentifier,
         Event,
     ]
diff --git a/tests/utils/test_syncify.py b/tests/utils/test_syncify.py
new file mode 100644
index 00000000..6f75d9e8
--- /dev/null
+++ b/tests/utils/test_syncify.py
@@ -0,0 +1,57 @@
+import asyncio
+
+import dspy
+
+
+def test_syncify_in_place():
+    class MyProgram(dspy.Module):
+        async def aforward(self, x: int) -> int:
+            await asyncio.sleep(0.01)
+            return x + 1
+
+    sync_program = dspy.syncify(MyProgram())
+    assert sync_program(1) == 2
+    assert sync_program(2) == 3
+
+
+def test_syncify_with_wrapper():
+    class MyProgram(dspy.Module):
+        async def aforward(self, x: int) -> int:
+            await asyncio.sleep(0.01)
+            return x + 1
+
+    sync_program = dspy.syncify(MyProgram(), in_place=False)
+    assert sync_program(1) == 2
+    assert sync_program(2) == 3
+
+
+def test_syncify_works_with_optimizers():
+    class MyProgram(dspy.Module):
+        def __init__(self):
+            self.predict = dspy.Predict("question->answer")
+
+        async def aforward(self, question: str):
+            return await self.predict.acall(question=question)
+
+    async_program = MyProgram()
+
+    def dummy_metric(gold, pred, traces=None):
+        return True
+
+    # We only test the optimizer completes without errors, so the LM response doesn't matter.
+    lm = dspy.utils.DummyLM([{"answer": "dummy"} for _ in range(100)])
+    dspy.configure(lm=lm)
+
+    dataset = [dspy.Example(question="question", answer="answer").with_inputs("question") for _ in range(10)]
+
+    optimizer = dspy.BootstrapFewShot(metric=dummy_metric, max_bootstrapped_demos=2, max_labeled_demos=0)
+
+    # Test syncify in place
+    sync_program = dspy.syncify(async_program, in_place=True)
+    optimized_program = optimizer.compile(sync_program, trainset=dataset)
+    assert len(optimized_program.predictors()[0].demos) == 2
+
+    # Test syncify with wrapper
+    sync_program = dspy.syncify(async_program, in_place=False)
+    optimized_program = optimizer.compile(sync_program, trainset=dataset)
+    assert len(optimized_program.predictors()[0].demos) == 2