Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion dspy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,23 @@
from dspy.clients import * # isort: skip
from dspy.adapters import Adapter, ChatAdapter, JSONAdapter, XMLAdapter, TwoStepAdapter, Image, Audio, File, History, Type, Tool, ToolCalls, ToolCallResults, Code, Reasoning # isort: skip
from dspy.primitives.sandbox_serializable import SandboxSerializable # isort: skip
from dspy.utils.exceptions import ContextWindowExceededError
from dspy.utils.exceptions import (
ContextWindowExceededError,
DSPyError,
LMAuthError,
LMBillingError,
LMConfigurationError,
LMError,
LMInvalidRequestError,
LMNotConfiguredError,
LMProviderError,
LMRateLimitError,
LMServerError,
LMTimeoutError,
LMTransportError,
LMUnsupportedFeatureError,
LMUnsupportedModelError,
)
from dspy.utils.logging_utils import configure_dspy_loggers, disable_logging, enable_logging
from dspy.utils.asyncify import asyncify
from dspy.utils.syncify import syncify
Expand Down
13 changes: 2 additions & 11 deletions dspy/adapters/_type_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,17 @@

if TYPE_CHECKING:
from dspy.adapters.base import Adapter
from dspy.clients.base_lm import BaseLM
from dspy.signatures.signature import Signature


@dataclass(frozen=True)
class _LMCapabilities:
model: str
model_type: str
supported_params: frozenset[str]
supports_function_calling: bool
supports_response_schema: bool
supports_reasoning: bool


@dataclass(frozen=True)
class _CallContext:
adapter: Adapter
use_native_function_calling: bool
allow_parallel_tool_calls: bool | None
native_response_types: tuple[type[object], ...]
lm: _LMCapabilities
lm: BaseLM
lm_kwargs: dict[str, object] = field(default_factory=dict)
lm_default_kwargs: dict[str, object] = field(default_factory=dict)

Expand Down
32 changes: 14 additions & 18 deletions dspy/adapters/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
from dspy.adapters._type_runtime import (
_AdapterCallPlan,
_CallContext,
_LMCapabilities,
_merge_lm_config,
_OutputParser,
_TypeFeatureHandler,
Expand All @@ -30,7 +29,7 @@
from dspy.adapters.types.reasoning import Reasoning
from dspy.adapters.types.tool import ToolCallResults, ToolCalls
from dspy.adapters.utils import format_field_value, parse_value
from dspy.clients.base_lm import BaseLM
from dspy.clients.base_lm import BaseLM, LMCapabilities
from dspy.clients.openai_format import (
lm_response_from_legacy_outputs,
message_to_openai_chat,
Expand Down Expand Up @@ -59,6 +58,17 @@ def signature(self) -> type[Signature]:
return self.call_plan.render_signature


class _AdapterContextLM(BaseLM):
"""Minimal BaseLM used when rendering adapter messages without a real LM."""

def __init__(self, *, use_native_tool_calls: bool = False):
super().__init__(model="", model_type="chat", temperature=None, max_tokens=None)
self._capabilities = LMCapabilities(function_calling=use_native_tool_calls)

def get_capabilities(self) -> LMCapabilities:
return self._capabilities


class Adapter:
"""Base Adapter class.

Expand Down Expand Up @@ -116,14 +126,7 @@ def build_call_context(self, lm: BaseLM, lm_kwargs: dict[str, Any] | None = None
use_native_function_calling=self.use_native_function_calling,
allow_parallel_tool_calls=self.allow_parallel_tool_calls,
native_response_types=tuple(self.native_response_types),
lm=_LMCapabilities(
model=lm.model,
model_type=getattr(lm, "model_type", "chat"),
supported_params=frozenset(getattr(lm, "supported_params", set())),
supports_function_calling=bool(getattr(lm, "supports_function_calling", False)),
supports_response_schema=bool(getattr(lm, "supports_response_schema", False)),
supports_reasoning=bool(getattr(lm, "supports_reasoning", False)),
),
lm=lm,
lm_kwargs=dict(lm_kwargs or {}),
lm_default_kwargs=dict(getattr(lm, "kwargs", {}) or {}),
)
Expand All @@ -134,14 +137,7 @@ def _default_call_context(self, *, use_native_tool_calls: bool = False) -> _Call
use_native_function_calling=use_native_tool_calls,
allow_parallel_tool_calls=self.allow_parallel_tool_calls,
native_response_types=tuple(self.native_response_types),
lm=_LMCapabilities(
model="",
model_type="chat",
supported_params=frozenset(),
supports_function_calling=use_native_tool_calls,
supports_response_schema=False,
supports_reasoning=False,
),
lm=_AdapterContextLM(use_native_tool_calls=use_native_tool_calls),
)

def value_to_lm_parts(self, value: object, field_info: FieldInfo) -> list[LMPart]:
Expand Down
7 changes: 4 additions & 3 deletions dspy/clients/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from typing import Any

from dspy.clients._litellm import get_litellm
from dspy.clients.base_lm import BaseLM, inspect_history
from dspy.clients.base_lm import BaseLM, LMCapabilities, inspect_history
from dspy.clients.cache import Cache
from dspy.clients.embedding import Embedder
from dspy.clients.lm import LM
Expand Down Expand Up @@ -39,7 +39,7 @@ def configure_cache(
safe_types: Additional types to allow when restrict_pickle is True.
"""

DSPY_CACHE = Cache(
dspy_cache = Cache(
enable_disk_cache,
enable_memory_cache,
disk_cache_dir,
Expand All @@ -52,7 +52,7 @@ def configure_cache(
import dspy

# Update the reference to point to the new cache
dspy.cache = DSPY_CACHE
dspy.cache = dspy_cache



Expand Down Expand Up @@ -112,6 +112,7 @@ def disable_litellm_logging():

__all__ = [
"BaseLM",
"LMCapabilities",
"LM",
"Provider",
"TrainingJob",
Expand Down
88 changes: 78 additions & 10 deletions dspy/clients/base_lm.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import copy as copy_module
import datetime
import uuid
from dataclasses import dataclass, field
from typing import Any, TextIO

from typing_extensions import Self

from dspy.dsp.utils import settings
from dspy.utils.callback import with_callbacks
from dspy.utils.inspect_history import pretty_print_history
Expand All @@ -10,6 +14,27 @@
GLOBAL_HISTORY = []


@dataclass(frozen=True)
class LMCapabilities:
"""Optional model and deployment metadata for an LM backend.

Capabilities are descriptive hints. Adapters can use them to select native
paths, but concrete LM implementations still decide how to handle requests.
"""

function_calling: bool = False
reasoning: bool = False
response_schema: bool = False
streaming: bool = False
input_image: bool = False
input_audio: bool = False
input_file: bool = False
output_image: bool = False
output_audio: bool = False
tool_results: bool = False
extensions: dict[str, Any] = field(default_factory=dict)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Mutable extensions dict inside a frozen=True dataclass

LMCapabilities is declared frozen=True, which prevents re-assigning the extensions field reference, but the dict itself remains mutable. Code that holds a reference to a LMCapabilities object and later calls capabilities.extensions["supported_params"] = new_set would silently mutate the shared state. Since get_capabilities() in LM constructs a fresh LMCapabilities on every call there is no shared state today, but any subclass that caches the result (the natural optimisation) could be affected. Switching extensions to types.MappingProxyType would make the immutability guarantee consistent.



class BaseLM:
"""Base class for handling LLM calls.

Expand Down Expand Up @@ -60,32 +85,55 @@ def forward(self, prompt, messages=None, **kwargs):
```
"""

def __init__(self, model, model_type="chat", temperature=0.0, max_tokens=1000, cache=True, **kwargs):
def __init__(
self,
model,
model_type="chat",
temperature=0.0,
max_tokens=1000,
cache=True,
callbacks=None,
num_retries=0,
**kwargs,
):
self.model = model
self.model_type = model_type
self.cache = cache
self.callbacks = callbacks or []
self.num_retries = num_retries
self.kwargs = dict(temperature=temperature, max_tokens=max_tokens, **kwargs)
self.history = []
self._warned_zero_temp_rollout = False

@property
def capabilities(self) -> LMCapabilities:
"""Native metadata available for this model instance."""
return self.get_capabilities()

def get_capabilities(self) -> LMCapabilities:
"""Return optional native model and deployment hints."""
return LMCapabilities()

@property
def supports_function_calling(self) -> bool:
"""Whether the model supports function calling (tool use)."""
return False
return self.capabilities.function_calling

@property
def supports_reasoning(self) -> bool:
"""Whether the model supports native reasoning (extended thinking)."""
return False
return self.capabilities.reasoning

@property
def supports_response_schema(self) -> bool:
"""Whether the model supports structured output via response schema."""
return False
return self.capabilities.response_schema

@property
def supported_params(self) -> set[str]:
"""Set of supported OpenAI-style parameter names for the model."""
return set()
supported = self.capabilities.extensions.get("supported_params", set())
return set(supported) if supported else set()

def _process_lm_response(self, response, prompt, messages, **kwargs):
merged_kwargs = {**self.kwargs, **kwargs}
Expand Down Expand Up @@ -190,23 +238,43 @@ async def aforward(
"""
raise NotImplementedError("Subclasses must implement this method.")

def dump_state(self) -> dict[str, Any]:
"""Return a sanitized reconstruction state for this LM."""
filtered_kwargs = {key: value for key, value in self.kwargs.items() if key != "api_key"}
return {
"model": self.model,
"model_type": self.model_type,
"cache": self.cache,
"num_retries": self.num_retries,
**filtered_kwargs,
}

@classmethod
def load_state(cls, state: dict[str, Any]) -> Self:
"""Reconstruct this LM from `dump_state()` output."""
return cls(**state)

def copy(self, **kwargs):
"""Returns a copy of the language model with possibly updated parameters.

Any provided keyword arguments update the corresponding attributes or LM kwargs of
the copy. For example, ``lm.copy(rollout_id=1, temperature=1.0)`` returns an LM whose
requests use a different rollout ID at non-zero temperature to bypass cache collisions.
"""

import copy
The default implementation uses a shallow runtime copy so provider clients, sessions,
and local model handles are preserved by reference while DSPy-owned mutable state is
isolated on the copy.
"""

new_instance = copy.deepcopy(self)
new_instance = copy_module.copy(self)
new_instance.history = []
new_instance.callbacks = list(getattr(self, "callbacks", []) or [])
new_instance.kwargs = dict(getattr(self, "kwargs", {}) or {})

for key, value in kwargs.items():
if hasattr(self, key):
if hasattr(new_instance, key):
setattr(new_instance, key, value)
if (key in self.kwargs) or (not hasattr(self, key)):
if (key in new_instance.kwargs) or (not hasattr(self, key)):
if value is None:
new_instance.kwargs.pop(key, None)
else:
Expand Down
12 changes: 11 additions & 1 deletion dspy/clients/lm.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from dspy.utils.callback import BaseCallback
from dspy.utils.exceptions import ContextWindowExceededError

from .base_lm import BaseLM
from .base_lm import BaseLM, LMCapabilities

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -123,6 +123,16 @@ def _provider_name(self) -> str:
return self.model.split("/", 1)[0]
return "openai"

def get_capabilities(self) -> LMCapabilities:
params = self.supported_params
return LMCapabilities(
function_calling=self.supports_function_calling,
reasoning=self.supports_reasoning,
response_schema=self.supports_response_schema,
streaming=True,
extensions={"supported_params": params},
)

@property
def supports_function_calling(self) -> bool:
return _get_litellm().supports_function_calling(model=self.model)
Expand Down
Loading
Loading