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
17 changes: 7 additions & 10 deletions dspy/clients/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@
from pathlib import Path
from typing import Any

import litellm

from dspy.clients._litellm import get_litellm
from dspy.clients.base_lm import BaseLM, inspect_history
from dspy.clients.cache import Cache
from dspy.clients.embedding import Embedder
Expand Down Expand Up @@ -56,9 +55,6 @@ def configure_cache(
dspy.cache = DSPY_CACHE


litellm.telemetry = False
litellm.cache = None # By default we disable LiteLLM cache and use DSPy on-disk cache.


def _get_dspy_cache():
disk_cache_dir = os.environ.get("DSPY_CACHEDIR") or os.path.join(Path.home(), ".dspy_cache")
Expand Down Expand Up @@ -91,7 +87,8 @@ def _get_dspy_cache():
def configure_litellm_logging(level: str = "ERROR"):
"""Configure LiteLLM logging to the specified level."""
# Litellm uses a global logger called `verbose_logger` to control all loggings.
from litellm._logging import verbose_logger
litellm = get_litellm(feature="LiteLLM logging")
verbose_logger = litellm._logging.verbose_logger

numeric_logging_level = getattr(logging, level)

Expand All @@ -101,18 +98,18 @@ def configure_litellm_logging(level: str = "ERROR"):


def enable_litellm_logging():
litellm = get_litellm(feature="LiteLLM logging")
litellm.suppress_debug_info = False
litellm._dspy_logging_configured = True
configure_litellm_logging("DEBUG")


def disable_litellm_logging():
litellm = get_litellm(feature="LiteLLM logging")
litellm.suppress_debug_info = True
litellm._dspy_logging_configured = True
configure_litellm_logging("ERROR")
Comment thread
greptile-apps[bot] marked this conversation as resolved.


# By default, we disable LiteLLM logging for clean logging
disable_litellm_logging()

__all__ = [
"BaseLM",
"LM",
Expand Down
42 changes: 42 additions & 0 deletions dspy/clients/_litellm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from __future__ import annotations

import functools
import sys
import types
from typing import Any

from dspy.utils.lazy_import import require


@functools.cache
def _configure_litellm_defaults(litellm: types.ModuleType) -> None:
"""Apply DSPy's global LiteLLM defaults once when LiteLLM is first imported."""
litellm.telemetry = False
litellm.cache = None # By default we disable LiteLLM cache and use DSPy on-disk cache.
if not getattr(litellm, "_dspy_logging_configured", False):
litellm.suppress_debug_info = True
litellm._dspy_logging_configured = True
Comment on lines +11 to +18

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 litellm.cache and litellm.telemetry unconditionally overridden at first use

Previously these were set at import dspy time, meaning any user who configured litellm.cache after import dspy would see their value respected on all subsequent calls. With the deferred approach, _configure_litellm_defaults now runs on the first LM call, which is after user code that configures litellm has already run. A user who sets litellm.cache = RedisCache(...) between import dspy and their first dspy.LM(...) invocation will have their cache silently reset to None. The _dspy_logging_configured guard was added for the logging path but there is no equivalent guard for cache or telemetry.

Suggested change
@functools.cache
def _configure_litellm_defaults(litellm: types.ModuleType) -> None:
"""Apply DSPy's global LiteLLM defaults once when LiteLLM is first imported."""
litellm.telemetry = False
litellm.cache = None # By default we disable LiteLLM cache and use DSPy on-disk cache.
if not getattr(litellm, "_dspy_logging_configured", False):
litellm.suppress_debug_info = True
litellm._dspy_logging_configured = True
@functools.cache
def _configure_litellm_defaults(litellm: types.ModuleType) -> None:
"""Apply DSPy's global LiteLLM defaults once when LiteLLM is first imported."""
if not getattr(litellm, "_dspy_telemetry_configured", False):
litellm.telemetry = False
litellm._dspy_telemetry_configured = True
if not getattr(litellm, "_dspy_cache_configured", False):
litellm.cache = None # By default we disable LiteLLM cache and use DSPy on-disk cache.
litellm._dspy_cache_configured = True
if not getattr(litellm, "_dspy_logging_configured", False):
litellm.suppress_debug_info = True
litellm._dspy_logging_configured = True



def _materialize_litellm(litellm: types.ModuleType) -> None:
"""Force LiteLLM's lazy module to execute, or raise the missing dependency error."""
# `require()` returns either an importlib LazyLoader-backed module or a _MissingModule.
# Accessing a real LiteLLM attribute forces LazyLoader execution; on _MissingModule it raises
# the helpful install-hint ImportError immediately at the DSPy call site.
_completion = litellm.completion


@functools.cache
def get_litellm(*, feature: str) -> Any:
"""Import LiteLLM, apply DSPy's defaults once, and return the module."""
litellm = require("litellm", extra="litellm", feature=feature)
_materialize_litellm(litellm)
_configure_litellm_defaults(litellm)
return litellm


def is_litellm_context_window_error(error: Exception) -> bool:
"""Return whether an exception is LiteLLM's context-window error, if LiteLLM is loaded."""
litellm_module = sys.modules.get("litellm")
context_window_error = getattr(litellm_module, "ContextWindowExceededError", None)
return context_window_error is not None and isinstance(error, context_window_error)
16 changes: 10 additions & 6 deletions dspy/clients/embedding.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@

from typing import Any, Callable

import litellm

from dspy.clients._litellm import get_litellm
from dspy.clients.cache import request_cache
from dspy.utils.lazy_import import require

np = require("numpy")


def _get_litellm():
return get_litellm(feature="dspy.Embedder")


class Embedder:
"""DSPy embedding class.

Expand Down Expand Up @@ -152,8 +156,8 @@ async def acall(self, inputs, batch_size=None, caching=None, **kwargs):

def _compute_embeddings(model, batch_inputs, caching=False, **kwargs):
if isinstance(model, str):
caching = caching and litellm.cache is not None
embedding_response = litellm.embedding(model=model, input=batch_inputs, caching=caching, **kwargs)
caching = caching and _get_litellm().cache is not None
embedding_response = _get_litellm().embedding(model=model, input=batch_inputs, caching=caching, **kwargs)
return [data["embedding"] for data in embedding_response.data]
elif callable(model):
return model(batch_inputs, **kwargs)
Expand All @@ -168,8 +172,8 @@ def _cached_compute_embeddings(model, batch_inputs, caching=True, **kwargs):

async def _acompute_embeddings(model, batch_inputs, caching=False, **kwargs):
if isinstance(model, str):
caching = caching and litellm.cache is not None
embedding_response = await litellm.aembedding(model=model, input=batch_inputs, caching=caching, **kwargs)
caching = caching and _get_litellm().cache is not None
embedding_response = await _get_litellm().aembedding(model=model, input=batch_inputs, caching=caching, **kwargs)
return [data["embedding"] for data in embedding_response.data]
elif callable(model):
return model(batch_inputs, **kwargs)
Expand Down
43 changes: 25 additions & 18 deletions dspy/clients/lm.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,11 @@
from typing import Any, Literal, cast

import anyio.from_thread
import litellm
import pydantic
from anyio.streams.memory import MemoryObjectSendStream
from litellm import ContextWindowExceededError as LitellmContextWindowExceededError

import dspy
from dspy.clients._litellm import get_litellm, is_litellm_context_window_error
from dspy.clients.cache import request_cache
from dspy.clients.openai import OpenAIProvider
from dspy.clients.provider import Provider, ReinforceJob, TrainingJob
Expand All @@ -26,6 +25,10 @@
logger = logging.getLogger(__name__)


def _get_litellm():
return get_litellm(feature="dspy.LM")


class LM(BaseLM):
"""
A language model supporting chat or text completion requests for use with DSPy modules.
Expand Down Expand Up @@ -122,19 +125,19 @@ def _provider_name(self) -> str:

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

@property
def supports_reasoning(self) -> bool:
return litellm.supports_reasoning(self.model)
return _get_litellm().supports_reasoning(self.model)

@property
def supports_response_schema(self) -> bool:
return litellm.supports_response_schema(model=self.model, custom_llm_provider=self._provider_name)
return _get_litellm().supports_response_schema(model=self.model, custom_llm_provider=self._provider_name)

@property
def supported_params(self) -> set[str]:
params = litellm.get_supported_openai_params(model=self.model, custom_llm_provider=self._provider_name)
params = _get_litellm().get_supported_openai_params(model=self.model, custom_llm_provider=self._provider_name)
return set(params) if params else set()

def _warn_zero_temp_rollout(self, temperature: float | None, rollout_id):
Expand Down Expand Up @@ -189,8 +192,10 @@ def forward(
num_retries=self.num_retries,
cache=litellm_cache_args,
)
except LitellmContextWindowExceededError as e:
raise ContextWindowExceededError(model=self.model) from e
except Exception as e:
if is_litellm_context_window_error(e):
raise ContextWindowExceededError(model=self.model) from e
raise

self._check_truncation(results)

Expand Down Expand Up @@ -230,8 +235,10 @@ async def aforward(
num_retries=self.num_retries,
cache=litellm_cache_args,
)
except LitellmContextWindowExceededError as e:
raise ContextWindowExceededError(model=self.model) from e
except Exception as e:
if is_litellm_context_window_error(e):
raise ContextWindowExceededError(model=self.model) from e
raise

self._check_truncation(results)

Expand Down Expand Up @@ -355,7 +362,7 @@ def _get_stream_completion_fn(
request["stream_options"] = {"include_usage": True}

async def stream_completion(request: dict[str, Any], cache_kwargs: dict[str, Any]):
response = await litellm.acompletion(
response = await _get_litellm().acompletion(
cache=cache_kwargs,
stream=True,
headers=headers,
Expand All @@ -368,7 +375,7 @@ async def stream_completion(request: dict[str, Any], cache_kwargs: dict[str, Any
chunk.predict_id = caller_predict_id
chunks.append(chunk)
await stream.send(chunk)
return litellm.stream_chunk_builder(chunks)
return _get_litellm().stream_chunk_builder(chunks)

def sync_stream_completion():
return anyio.from_thread.run(functools.partial(stream_completion, request, cache_kwargs))
Expand All @@ -389,7 +396,7 @@ def litellm_completion(request: dict[str, Any], num_retries: int, cache: dict[st
headers = _add_dspy_identifier_to_headers(request.pop("headers", None))
stream_completion = _get_stream_completion_fn(request, cache, sync=True, headers=headers)
if stream_completion is None:
return litellm.completion(
return _get_litellm().completion(
cache=cache,
num_retries=num_retries,
retry_strategy="exponential_backoff_retry",
Expand Down Expand Up @@ -417,7 +424,7 @@ def litellm_text_completion(request: dict[str, Any], num_retries: int, cache: di
# Build the prompt from the messages.
prompt = "\n\n".join([x["content"] for x in request.pop("messages")] + ["BEGIN RESPONSE:"])

return litellm.text_completion(
return _get_litellm().text_completion(
cache=cache,
model=f"text-completion-openai/{model}",
api_key=api_key,
Expand All @@ -437,7 +444,7 @@ async def alitellm_completion(request: dict[str, Any], num_retries: int, cache:
headers = _add_dspy_identifier_to_headers(request.pop("headers", None))
stream_completion = _get_stream_completion_fn(request, cache, sync=False, headers=headers)
if stream_completion is None:
return await litellm.acompletion(
return await _get_litellm().acompletion(
cache=cache,
num_retries=num_retries,
retry_strategy="exponential_backoff_retry",
Expand All @@ -463,7 +470,7 @@ async def alitellm_text_completion(request: dict[str, Any], num_retries: int, ca
# Build the prompt from the messages.
prompt = "\n\n".join([x["content"] for x in request.pop("messages")] + ["BEGIN RESPONSE:"])

return await litellm.atext_completion(
return await _get_litellm().atext_completion(
cache=cache,
model=f"text-completion-openai/{model}",
api_key=api_key,
Expand All @@ -483,7 +490,7 @@ def litellm_responses_completion(request: dict[str, Any], num_retries: int, cach
headers = request.pop("headers", None)
request = _convert_chat_request_to_responses_request(request)

return litellm.responses(
return _get_litellm().responses(
cache=cache,
num_retries=num_retries,
retry_strategy="exponential_backoff_retry",
Expand All @@ -499,7 +506,7 @@ async def alitellm_responses_completion(request: dict[str, Any], num_retries: in
headers = request.pop("headers", None)
request = _convert_chat_request_to_responses_request(request)

return await litellm.aresponses(
return await _get_litellm().aresponses(
cache=cache,
num_retries=num_retries,
retry_strategy="exponential_backoff_retry",
Expand Down
12 changes: 8 additions & 4 deletions dspy/streaming/streamify.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,9 @@
from queue import Queue
from typing import TYPE_CHECKING, Any, AsyncGenerator, Awaitable, Callable, Generator

import litellm
import orjson
from anyio import create_memory_object_stream, create_task_group
from anyio.streams.memory import MemoryObjectSendStream
from litellm import ModelResponseStream

from dspy.dsp.utils.settings import settings
from dspy.primitives.prediction import Prediction
Expand All @@ -20,6 +18,12 @@

logger = logging.getLogger(__name__)


def _is_litellm_model_response_stream(value: Any) -> bool:
cls = type(value)
return cls.__name__ == "ModelResponseStream" and cls.__module__.startswith("litellm")
Comment on lines +22 to +24

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 Exact type-name check misses subclasses of ModelResponseStream

type(value).__name__ == "ModelResponseStream" only matches the concrete class, not subclasses. If litellm ever introduces a more-specific subclass (e.g., per-provider variants), those objects would fall through and be yielded as raw values rather than being dispatched to listeners. The previous isinstance(value, ModelResponseStream) correctly walked the MRO. Using issubclass on the type preserves the lazy-import approach while restoring inheritance-aware matching.

Suggested change
def _is_litellm_model_response_stream(value: Any) -> bool:
cls = type(value)
return cls.__name__ == "ModelResponseStream" and cls.__module__.startswith("litellm")
def _is_litellm_model_response_stream(value: Any) -> bool:
cls = type(value)
return cls.__module__.startswith("litellm") and any(
c.__name__ == "ModelResponseStream" for c in cls.__mro__
)



if TYPE_CHECKING:
from dspy.primitives.module import Module

Expand Down Expand Up @@ -178,7 +182,7 @@ async def async_streamer(*args, **kwargs):
tg.start_soon(generator, args, kwargs, send_stream)

async for value in receive_stream:
if isinstance(value, ModelResponseStream):
if _is_litellm_model_response_stream(value):
if len(predict_id_to_listener) == 0:
# No listeners are configured, yield the chunk directly for backwards compatibility.
yield value
Expand Down Expand Up @@ -271,7 +275,7 @@ async def streaming_response(streamer: AsyncGenerator) -> AsyncGenerator:
if isinstance(value, Prediction):
data = {"prediction": dict(value.items(include_dspy=False))}
yield f"data: {orjson.dumps(data).decode()}\n\n"
elif isinstance(value, litellm.ModelResponseStream):
elif _is_litellm_model_response_stream(value):
data = {"chunk": value.json()}
yield f"data: {orjson.dumps(data).decode()}\n\n"
elif isinstance(value, str) and value.startswith("data:"):
Expand Down
9 changes: 6 additions & 3 deletions dspy/streaming/streaming_listener.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from __future__ import annotations

import inspect
import re
from collections import defaultdict
from queue import Queue
from typing import TYPE_CHECKING, Any

import jiter
from litellm import ModelResponseStream

from dspy.adapters.chat_adapter import ChatAdapter
from dspy.adapters.json_adapter import JSONAdapter
Expand All @@ -15,6 +16,8 @@
from dspy.streaming.messages import StreamResponse

if TYPE_CHECKING:
from litellm import ModelResponseStream

from dspy.primitives.module import Module

ADAPTER_SUPPORT_STREAMING = [ChatAdapter, XMLAdapter, JSONAdapter]
Expand Down Expand Up @@ -368,7 +371,7 @@ def _output_type(self) -> type | None:


def find_predictor_for_stream_listeners(
program: "Module", stream_listeners: list[StreamListener]
program: Module, stream_listeners: list[StreamListener]
) -> dict[int, list[StreamListener]]:
"""Find the predictor for each stream listener.

Expand All @@ -385,7 +388,7 @@ def find_predictor_for_stream_listeners(
field_name_to_named_predictor[listener.signature_field_name] = None

for name, predictor in predictors:
for field_name, field_info in predictor.signature.output_fields.items():
for field_name in predictor.signature.output_fields:
if field_name not in field_name_to_named_predictor:
continue

Expand Down
1 change: 1 addition & 0 deletions dspy/utils/lazy_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ def _detect_dspy_dist() -> str:
"weaviate": "weaviate",
"anthropic": "anthropic",
"numpy": "numpy",
"litellm": "litellm",
}


Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ mcp = ["mcp; python_version >= '3.10'"]
langchain = ["langchain_core"]
optuna = ["optuna>=3.4.0"]
numpy = ["numpy>=1.26.0"]
litellm = ["litellm>=1.64.0"]
dev = [
"pytest>=6.2.5",
"pytest-mock>=3.12.0",
Expand Down
Loading
Loading