Skip to content

Drop hard dependency on openai#216

Merged
rvlb merged 12 commits intomainfrom
django-ai-assistant-optional-providers
Mar 23, 2026
Merged

Drop hard dependency on openai#216
rvlb merged 12 commits intomainfrom
django-ai-assistant-optional-providers

Conversation

@rvlb
Copy link
Copy Markdown
Contributor

@rvlb rvlb commented Jan 30, 2026

Summary by Sourcery

Introduce provider selection to AIAssistant and wire it into LLM and structured output creation.

New Features:

  • Allow AIAssistant instances to be initialized with a configurable provider, defaulting to OpenAI.
  • Add a simple provider enumeration to represent supported LLM providers.

Enhancements:

  • Route LLM creation through the selected provider and validate OpenAI dependency at runtime.
  • Base structured-output behavior on the configured provider instead of concrete LLM class checks.

Closes #207

Summary by Sourcery

Introduce configurable LLM providers for AIAssistant and remove the hard runtime and install-time dependency on OpenAI-specific classes.

New Features:

  • Allow AIAssistant instances to be initialized with a configurable LLM provider key, defaulting to OpenAI.
  • Add a provider-to-LLM mapping that dynamically imports the appropriate LangChain chat model per provider.

Enhancements:

  • Route LLM and structured-output creation through the configured provider instead of directly depending on ChatOpenAI.
  • Validate provider keys and surface clear errors when a provider is unknown, missing, or misconfigured.

Build:

  • Make langchain provider libraries optional dependencies exposed via Poetry extras and wire them into tox runs.

Tests:

  • Extend AIAssistant tests to cover provider-specific LLM selection, invalid providers, missing provider installations, and misconfigured provider classes, and update existing mocks to target provider modules.

@sourcery-ai
Copy link
Copy Markdown
Contributor

sourcery-ai Bot commented Jan 30, 2026

Reviewer's Guide

Adds configurable LLM provider support to AIAssistant, deferring provider-specific imports to runtime and making OpenAI/Anthropic/Google dependencies optional, while updating structured output behavior, tests, and packaging to reflect provider-based selection instead of a hard ChatOpenAI dependency.

Sequence diagram for provider-based LLM resolution in get_llm

sequenceDiagram
    actor Caller
    participant AIAssistant
    participant ProviderLookup as PROVIDER_LLM_LOOKUP
    participant ImportSystem as importlib
    participant LangchainModule
    participant LLMClass

    Caller->>AIAssistant: __init__(provider)
    AIAssistant-->>AIAssistant: store _provider

    Caller->>AIAssistant: get_llm()
    activate AIAssistant
    AIAssistant->>AIAssistant: get_model(), get_temperature(), get_model_kwargs()

    AIAssistant->>ProviderLookup: get(_provider)
    alt provider not found
        ProviderLookup-->>AIAssistant: None
        AIAssistant-->>Caller: raise AIAssistantMisconfiguredError
    else provider found
        ProviderLookup-->>AIAssistant: ProviderConfig
        AIAssistant->>ImportSystem: import_module(langchain_module)
        alt langchain module missing
            ImportSystem-->>AIAssistant: ImportError
            AIAssistant-->>Caller: raise ImportError (extra install message)
        else module imported
            ImportSystem-->>LangchainModule: module
            AIAssistant->>LangchainModule: getattr(llm_class)
            alt llm_class attribute missing
                LangchainModule-->>AIAssistant: AttributeError
                AIAssistant-->>Caller: raise ImportError (invalid LLM class)
            else llm_class found
                LangchainModule-->>LLMClass: class
                AIAssistant-->>AIAssistant: construct llm with model, temperature, model_kwargs
                AIAssistant-->>Caller: llm instance
            end
        end
    end
    deactivate AIAssistant
Loading

Class diagram for updated AIAssistant provider support

classDiagram
    class AIAssistant {
        <<abstract>>
        - user
        - request
        - view
        - _method_tools Sequence_BaseTool
        - _provider ProviderName
        - _init_kwargs dict_str_Any
        + __init__(user, request, view, provider, **kwargs)
        + get_llm() BaseChatModel
        + get_structured_output_llm() Runnable
        + get_model() str
        + get_temperature() float
        + get_model_kwargs() dict_str_Any
        - _import_llm_class()
    }

    class ProviderConfig {
        <<TypedDict>>
        + langchain_module str
        + llm_class str
    }

    class ProviderName {
        <<Literal>>
        + openai
        + anthropic
        + google
    }

    class PROVIDER_LLM_LOOKUP {
        <<dict[ProviderName,ProviderConfig]>>
    }

    AIAssistant --> ProviderName : uses
    AIAssistant --> ProviderConfig : resolves
    PROVIDER_LLM_LOOKUP --> ProviderName : keys
    PROVIDER_LLM_LOOKUP --> ProviderConfig : values
Loading

File-Level Changes

Change Details Files
Introduce provider configuration and runtime LLM-class resolution in AIAssistant instead of hardcoding ChatOpenAI.
  • Add ProviderName Literal and ProviderConfig TypedDict to represent supported providers and their langchain modules/classes.
  • Define PROVIDER_LLM_LOOKUP mapping provider keys to langchain modules and chat model class names for openai, anthropic, and google.
  • Extend AIAssistant.init to accept a provider argument (defaulting to 'openai') and store it on the instance.
  • Implement _import_llm_class to validate provider, import the appropriate langchain module, and fetch the configured LLM class with clear error messages for invalid providers, missing modules, or missing classes.
  • Refactor get_llm to use the dynamically imported llm_class for construction while preserving existing temperature/model_kwargs behavior.
django_ai_assistant/helpers/assistants.py
Base structured-output LLM behavior on the configured provider instead of concrete ChatOpenAI type checks.
  • Change get_structured_output_llm to detect OpenAI by checking self._provider == 'openai' instead of isinstance(llm, ChatOpenAI) when choosing json_mode/json_schema.
  • Preserve the json_schema preference only for the openai provider to keep strict structured outputs when available.
django_ai_assistant/helpers/assistants.py
Expand and adjust tests to cover provider-based LLM selection and error handling, and fix mocks to match new import locations.
  • Update existing AIAssistant.get_llm tests to patch langchain_openai.ChatOpenAI instead of django_ai_assistant.helpers.assistants.ChatOpenAI.
  • Add tests for get_llm with explicit openai, anthropic, and google providers asserting the correct langchain chat class is instantiated.
  • Add tests verifying misconfiguration/error scenarios: invalid provider key raises AIAssistantMisconfiguredError; missing provider module or incorrect LLM class path both raise ImportError with informative messages.
tests/test_helpers/test_assistants.py
Make LLM provider dependencies optional and expose them as Poetry extras, updating tox to install them for tests.
  • Remove the hard dependency on openai from core dependencies.
  • Mark langchain-openai, langchain-anthropic, and langchain-google-genai as optional dependencies and group them under poetry extras (openai, anthropic, google).
  • Ensure dev dependencies still include the LLM provider packages needed for development and testing.
  • Configure tox to install the openai, anthropic, and google extras when running the test environment so provider-specific tests have their deps available.
pyproject.toml
tox.ini
poetry.lock

Assessment against linked issues

Issue Objective Addressed Explanation
#207 Remove the hard runtime dependency on OpenAI from the core package (i.e., do not require OpenAI/langchain-openai to be installed for django-ai-assistant to be installable and usable).
#207 Introduce optional extras in pyproject.toml so that each major AI provider can be installed via extras, specifically django-ai-assistant[openai], django-ai-assistant[anthropic], and django-ai-assistant[google].

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@rvlb rvlb changed the title Add provider selecting flow to AIAssistant class Drop hard dependency on openai Jan 30, 2026
@codecov
Copy link
Copy Markdown

codecov Bot commented Jan 30, 2026

Codecov Report

❌ Patch coverage is 96.55172% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 94.10%. Comparing base (734cbd0) to head (a7f9f46).
⚠️ Report is 13 commits behind head on main.

Files with missing lines Patch % Lines
django_ai_assistant/helpers/assistants.py 96.55% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #216      +/-   ##
==========================================
+ Coverage   93.90%   94.10%   +0.20%     
==========================================
  Files          19       19              
  Lines         706      730      +24     
  Branches       51       54       +3     
==========================================
+ Hits          663      687      +24     
  Misses         33       33              
  Partials       10       10              
Flag Coverage Δ
backend-python-3.10 92.54% <96.55%> (+0.30%) ⬆️
backend-python-3.11 93.03% <96.55%> (+0.28%) ⬆️
backend-python-3.12 93.03% <96.55%> (+0.28%) ⬆️
backend-python-3.13 93.03% <96.55%> (+0.28%) ⬆️
frontend 100.00% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread django_ai_assistant/helpers/assistants.py Outdated
Comment thread django_ai_assistant/helpers/assistants.py Outdated
f"Install it with: pip install django-ai-assistant[{self._provider}]"
) from err

return getattr(
Copy link
Copy Markdown
Contributor Author

@rvlb rvlb Feb 2, 2026

Choose a reason for hiding this comment

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

I thought about adding a test for this getattr call since in theory it could fail if PROVIDER_LLM_LOOKUP is malformed (i.e.: the langchain lib name is correct but the chat class name isn't), but opted to leave it out since this is fully setup on our side (and the test itself wouldn't be actually testing if the real-life classes exist).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

to avoid malformed names, could we rely on some typing maybe?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'll give a look at that

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated. Before we merge this, I'll get back to investigate what's the issue with npm publishing flow.

@rvlb rvlb marked this pull request as ready for review March 20, 2026 18:54
Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 4 issues, and left some high level feedback:

  • In _import_llm_class, the error message for an invalid provider currently interpolates PROVIDER_LLM_LOOKUP.keys(), which will render as a dict_keys object; consider converting to a sorted, comma-separated string (e.g., ', '.join(PROVIDER_LLM_LOOKUP.keys())) to make the message clearer.
  • The provider identifiers ("openai", "anthropic", "google") are repeated across ProviderName, PROVIDER_LLM_LOOKUP, and call sites; consider centralizing them (e.g., via an Enum or module-level constants) to reduce the risk of typos and keep provider names in sync.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `_import_llm_class`, the error message for an invalid provider currently interpolates `PROVIDER_LLM_LOOKUP.keys()`, which will render as a `dict_keys` object; consider converting to a sorted, comma-separated string (e.g., `', '.join(PROVIDER_LLM_LOOKUP.keys())`) to make the message clearer.
- The provider identifiers (`"openai"`, `"anthropic"`, `"google"`) are repeated across `ProviderName`, `PROVIDER_LLM_LOOKUP`, and call sites; consider centralizing them (e.g., via an Enum or module-level constants) to reduce the risk of typos and keep provider names in sync.

## Individual Comments

### Comment 1
<location path="tests/test_helpers/test_assistants.py" line_range="478-479" />
<code_context>
+    AIAssistant.clear_cls_registry()
+
+
+def test_AIAssistant_get_llm_invalid_provider():
+    class InvalidAIAssistant(AIAssistant):
+        id = "override_invalid_assistant"  # noqa: A003
+        name = "Override Invalid Assistant"
+        instructions = "Instructions"
+        model = "gpt-test"
+
+    assistant = InvalidAIAssistant(provider="invalid")
+    with pytest.raises(AIAssistantMisconfiguredError):
+        assistant.get_llm()
+
</code_context>
<issue_to_address>
**suggestion (testing):** Assert the error message for invalid provider to guarantee helpful feedback remains intact

Right now the test only checks that `AIAssistantMisconfiguredError` is raised. To also verify the error remains user-friendly and still lists valid providers, consider capturing the exception and asserting on part of its message, e.g. `with pytest.raises(AIAssistantMisconfiguredError) as exc:` followed by `assert "supported providers" in str(exc.value)`.

```suggestion
    with pytest.raises(AIAssistantMisconfiguredError) as exc:
        assistant.get_llm()

    assert "supported providers" in str(exc.value)
```
</issue_to_address>

### Comment 2
<location path="tests/test_helpers/test_assistants.py" line_range="507-508" />
<code_context>
+        assistant.get_llm()
+
+
+def test_AIAssistant_get_llm_uninstalled_provider(monkeypatch):
+    class UninstalledAIAssistant(AIAssistant):
+        id = "override_uninstalled_assistant"  # noqa: A003
+        name = "Override Uninstalled Assistant"
+        instructions = "Instructions"
+        model = "gpt-test"
+
+    assistant = UninstalledAIAssistant(provider="uninstalled")
+
+    # Simulates a scenario where the user tries to use a valid provider
+    # that isn't installed with lib (i.e.: user tries to access the
+    # openai provider, but langchain_openai isn't installed)
+    from django_ai_assistant.helpers import assistants
+
+    monkeypatch.setattr(
+        assistants,
+        "PROVIDER_LLM_LOOKUP",
+        {
+            "uninstalled": {
+                "langchain_module": "test_module",
+                "llm_class": "UninstalledChat",
+            },
+        },
+    )
+
+    with pytest.raises(ImportError):
+        assistant.get_llm()
+
</code_context>
<issue_to_address>
**suggestion (testing):** Tighten assertions for uninstalled provider to cover the guidance in the ImportError message

Right now the test only asserts that an `ImportError` is raised. Since the runtime error includes a specific installation hint (`pip install django-ai-assistant[provider]`), please also assert that the message contains the expected guidance (e.g. references `pip install django-ai-assistant[uninstalled]` or the configured module name) to better lock in the expected developer experience and avoid regressions in the error text.

```suggestion
    with pytest.raises(ImportError) as excinfo:
        assistant.get_llm()

    assert "pip install django-ai-assistant[uninstalled]" in str(excinfo.value)
```
</issue_to_address>

### Comment 3
<location path="tests/test_helpers/test_assistants.py" line_range="537-538" />
<code_context>
+        assistant.get_llm()
+
+
 @pytest.mark.vcr
 def test_AIAssistant_pydantic_structured_output():
     from pydantic import BaseModel
</code_context>
<issue_to_address>
**suggestion (testing):** Add tests to ensure `get_structured_output_llm` behavior varies correctly by provider

This change now branches on `self._provider == "openai"` instead of `isinstance(llm, ChatOpenAI)`, but current tests only cover `get_llm`, not this provider-specific behavior. Please add tests that assert: (1) for `openai`, `with_structured_output(..., method="json_schema")` is used, and (2) for non‑OpenAI providers, `method="json_mode"` is used. You can likely do this with small unit tests by mocking `get_llm` / the LLM object rather than using VCR.
</issue_to_address>

### Comment 4
<location path="django_ai_assistant/helpers/assistants.py" line_range="56" />
<code_context>
 from django_ai_assistant.langchain.tools import tool as tool_decorator


+ProviderName = Literal["openai", "anthropic", "google"]
+
+
</code_context>
<issue_to_address>
**issue (complexity):** Consider replacing the string-based provider config and inline conditionals with small factory and method maps that resolve provider-specific classes and behavior once, making the multi-provider design clearer and more maintainable.

You can keep the multi‑provider feature but simplify the indirection and make behavior more explicit with a few small refactors.

### 1. Replace `ProviderConfig` + stringly imports with small factory map

You don’t really need `TypedDict`, string class names, and `getattr`. A small factory map gives you:

- deferred imports per provider
- type safety (no string class names)
- no repeated `importlib` + `getattr` on every call

```python
# keep ProviderName
ProviderName = Literal["openai", "anthropic", "google"]

def _resolve_llm_class(provider: ProviderName) -> type[BaseChatModel]:
    try:
        return PROVIDER_LLM_FACTORIES[provider]()
    except KeyError:
        valid = ", ".join(PROVIDER_LLM_FACTORIES.keys())
        raise AIAssistantMisconfiguredError(
            f"Invalid provider={provider}, please use one of: {valid}"
        )

# small, explicit factories with deferred imports
PROVIDER_LLM_FACTORIES: dict[ProviderName, Callable[[], type[BaseChatModel]]] = {
    "openai": lambda: __import__("langchain_openai").langchain_openai.ChatOpenAI,
    "anthropic": lambda: __import__("langchain_anthropic").langchain_anthropic.ChatAnthropic,
    "google": lambda: __import__("langchain_google_genai").langchain_google_genai.ChatGoogleGenerativeAI,
}
```

Then cache the resolved class on the instance to avoid repeated lookup and keep the public surface “provider”-based:

```python
class AIAssistant(abc.ABC):
    _provider: ProviderName
    _llm_class: type[BaseChatModel]

    def __init__(..., provider: ProviderName = "openai", **kwargs: Any):
        ...
        self._provider = provider
        self._llm_class = _resolve_llm_class(provider)
        ...

    def get_llm(self) -> BaseChatModel:
        model = self.get_model()
        temperature = self.get_temperature()
        model_kwargs = self.get_model_kwargs()

        kwargs: dict[str, Any] = {"model": model, "model_kwargs": model_kwargs}
        if temperature is not None:
            kwargs["temperature"] = temperature

        return self._llm_class(**kwargs)
```

This keeps:

- the `provider` API
- deferred, per‑provider imports
- no stringly‑typed config or `TypedDict`
- no dynamic imports inside `get_llm` (resolution happens once in `__init__`)

### 2. Remove provider string check for structured output

Instead of a hard‑coded `if self._provider == "openai"`, centralize provider‑specific behavior in a small map. This scales better and avoids scattering provider checks:

```python
PROVIDER_STRUCTURED_METHOD: dict[ProviderName, str] = {
    "openai": "json_schema",
    # other providers can override in the future
}

class AIAssistant(abc.ABC):
    ...

    def get_structured_output_llm(self) -> Runnable:
        if not self.structured_output:
            raise ValueError("structured_output is not defined")

        llm = self.get_llm()
        method = PROVIDER_STRUCTURED_METHOD.get(self._provider, "json_mode")
        return llm.with_structured_output(self.structured_output, method=method)
```

This keeps all current behavior (OpenAI uses `json_schema`, others use `json_mode`), but removes a growing `if self._provider == ...` chain and keeps provider‑specific behavior explicit and local.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread tests/test_helpers/test_assistants.py
Comment thread tests/test_helpers/test_assistants.py
Comment thread tests/test_helpers/test_assistants.py
Comment thread django_ai_assistant/helpers/assistants.py
@rvlb rvlb requested a review from fjsj March 23, 2026 12:59
@rvlb rvlb merged commit 627e8b3 into main Mar 23, 2026
15 checks passed
@rvlb rvlb deleted the django-ai-assistant-optional-providers branch March 23, 2026 19:17
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.

Drop hard dependency on openai

2 participants