Skip to content

Make LiteLLM imports lazy - #49

Open
MaximeRivest wants to merge 2 commits into
cmpnd-ai:mainfrom
MaximeRivest:lazy-load-litellm
Open

Make LiteLLM imports lazy#49
MaximeRivest wants to merge 2 commits into
cmpnd-ai:mainfrom
MaximeRivest:lazy-load-litellm

Conversation

@MaximeRivest

Copy link
Copy Markdown

Summary

  • lazy-load LiteLLM so importing DSPy works without importing the dependency
  • configure LiteLLM defaults on first actual use via lazy import on-load hooks
  • avoid LiteLLM imports in streaming type checks and annotations
  • add tests for lazy import callbacks and DSPy import behavior without LiteLLM

Tests

  • python3 -m pytest tests/clients/test_lazy_litellm_import.py tests/utils/test_lazy_import.py -q
  • python3 -m pytest tests/adapters/test_json_adapter.py::test_error_message_on_json_adapter_failure tests/adapters/test_json_adapter.py::test_error_message_on_json_adapter_failure_async tests/clients/test_lm.py::test_exponential_backoff_retry tests/clients/test_lazy_litellm_import.py tests/utils/test_lazy_import.py -q

Co-authored-by: Copilot <copilot@github.com>
@greptile-apps

greptile-apps Bot commented May 14, 2026

Copy link
Copy Markdown

Greptile Summary

This PR makes LiteLLM an import-time-lazy dependency: import dspy no longer triggers litellm's (slow) module initialization. A new dspy/clients/_litellm.py module centralises access through a cached get_litellm() helper, and streaming type-checks are replaced with name+module-string guards to avoid importing litellm at all until first actual use.

  • Lazy module loading: get_litellm(feature=...) wraps require(\"litellm\"), forces materialization, applies DSPy defaults once (via @functools.cache), and returns the module; all litellm call sites in lm.py, embedding.py, and streamify.py are updated accordingly.
  • ContextWindowExceededError handling: changed from a direct except LitellmContextWindowExceededError clause to a runtime isinstance check via is_litellm_context_window_error, which inspects sys.modules so litellm need not be imported for the check itself.
  • Type annotations: streaming_listener.py gains from __future__ import annotations and moves ModelResponseStream under TYPE_CHECKING, replacing the runtime import cleanly.

Confidence Score: 4/5

Safe to merge with one caveat: litellm.cache and litellm.telemetry are now reset to DSPy defaults on the first LM call rather than at import time, which silently overrides any litellm configuration a user sets between import dspy and their first dspy.LM invocation.

The deferred configuration in _configure_litellm_defaults unconditionally sets litellm.cache = None and litellm.telemetry = False at first use. Previously applied at import dspy time, users who configured litellm after importing dspy would see their settings respected. Now the same configuration happens at first call, after user code has run, silently discarding any litellm.cache a user may have set.

dspy/clients/_litellm.py — the unconditional litellm.cache = None and litellm.telemetry = False assignments in _configure_litellm_defaults.

Important Files Changed

Filename Overview
dspy/clients/_litellm.py New module centralizing lazy LiteLLM access; _configure_litellm_defaults is cached but unconditionally sets litellm.cache = None at first use, which can silently override user-configured litellm cache settings.
dspy/clients/init.py Removes eager litellm import; configure_litellm_logging and enable/disable_litellm_logging now call get_litellm on demand; sets _dspy_logging_configured flag correctly.
dspy/clients/lm.py All litellm calls routed through _get_litellm(); ContextWindowExceededError handling changed from direct except clause to runtime isinstance check via is_litellm_context_window_error.
dspy/streaming/streamify.py Removes litellm import; introduces _is_litellm_model_response_stream name+module guard replacing isinstance(value, ModelResponseStream) checks.
dspy/streaming/streaming_listener.py Adds from __future__ import annotations; moves ModelResponseStream under TYPE_CHECKING; removes unused field_info from loop.
dspy/utils/lazy_import.py Adds litellm to _INSTALL_HINTS for helpful pip install dspy[litellm] error messages.
tests/clients/test_lazy_litellm_import.py New tests verifying lazy import behaviour via find_spec monkeypatching.
pyproject.toml Adds litellm optional extra; litellm remains a core required dependency.

Sequence Diagram

sequenceDiagram
    participant User
    participant DSPy
    participant _litellm as dspy/clients/_litellm.py
    participant require as lazy_import.require()
    participant LiteLLM

    User->>DSPy: import dspy
    Note over DSPy,LiteLLM: litellm NOT imported yet

    User->>DSPy: dspy.LM("model")("Hello")
    DSPy->>_litellm: "get_litellm(feature="dspy.LM")"
    _litellm->>require: require("litellm")
    alt litellm installed
        require-->>_litellm: LazyLoader-backed module
        _litellm->>LiteLLM: _materialize_litellm (access .completion)
        LiteLLM-->>_litellm: fully loaded module
        _litellm->>LiteLLM: _configure_litellm_defaults (once, cached)
        _litellm-->>DSPy: litellm module
        DSPy->>LiteLLM: litellm.completion(...)
        LiteLLM-->>DSPy: response
        DSPy-->>User: result
    else litellm not installed
        require-->>_litellm: _MissingModule stub
        _litellm->>_litellm: _materialize_litellm stub.__getattr__
        _litellm-->>User: ImportError("pip install dspy[litellm]")
    end
Loading

Reviews (3): Last reviewed commit: "Refine lazy LiteLLM loading" | Re-trigger Greptile

Comment on lines 277 to 278
def _set_random_seeds(self, seed):
self.rng = random.Random(seed)

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 Numpy global RNG no longer seeded — reproducibility regression

The removal of np.random.seed(seed) from _set_random_seeds silently breaks reproducibility for any code in the optimization loop that relies on numpy's global RNG (e.g., components using np.random.choice, np.random.shuffle, or any other np.random.* function). Since simba.py still requires numpy through require("numpy"), and optimizers often compose, runs with the same seed may now return different results each time numpy happens to be available. A conditional seed based on is_available("numpy") would restore the old behaviour without making numpy required.

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

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__
)

Comment thread dspy/clients/__init__.py
Comment thread dspy/clients/_litellm.py
Comment on lines +11 to +18
@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

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant