Skip to content

feat: add LiteLLM as AI gateway provider#440

Open
RheagalFire wants to merge 2 commits into
MigoXLab:mainfrom
RheagalFire:feat/add-litellm-provider
Open

feat: add LiteLLM as AI gateway provider#440
RheagalFire wants to merge 2 commits into
MigoXLab:mainfrom
RheagalFire:feat/add-litellm-provider

Conversation

@RheagalFire

Copy link
Copy Markdown

Summary

  • Adds BaseLiteLLM — a drop-in base class for Dingo's LLM evaluators that routes calls through LiteLLM, giving access to 100+ providers (Anthropic, Gemini, Bedrock, Groq, Cohere, Mistral, etc.) via a single unified interface
  • litellm is an optional extra (pip install "dingo-python[litellm]") — base install is unaffected
  • All existing evaluators that extend BaseOpenAI are untouched; BaseLiteLLM is purely additive

Motivation

Dingo's BaseOpenAI evaluators require an OpenAI-compatible api_url endpoint. Users who want to run evaluations against Anthropic, Google Gemini, AWS Bedrock, Groq, or any other provider today must either maintain a separate proxy or have no supported path. BaseLiteLLM removes that friction — specify a provider-prefixed model string and LiteLLM handles the routing automatically.

Changes

  • dingo/model/llm/base_litellm.py — new BaseLiteLLM class extending BaseOpenAI; overrides create_client() and send_messages() to use litellm.completion() with drop_params=True
  • setup.py — added litellm>=1.35.0,<2.0 under extras_require['litellm'] and included it in 'all'
  • test/scripts/model/llm/test_litellm.py — 13 unit tests covering dispatch, drop_params, key/url forwarding, extra params, length error, and response parsing

Implementation notes

  • drop_params=True by default — silently drops per-provider-unsupported kwargs (e.g. seed, strict, frequency_penalty fail on Anthropic/Gemini without this)
  • Lazy importlitellm is imported inside method bodies so the base install works without it
  • Sentinel client — sets cls.client = True after init so the eval() loop's if cls.client is None guard doesn't re-run initialization on every call
  • api_url maps to api_base — supports LiteLLM proxy deployments; key maps to api_key

Tests

1. Unit tests: pytest test/scripts/model/llm/test_litellm.py -v

test/scripts/model/llm/test_litellm.py::TestCreateClient::test_raises_without_model PASSED
test/scripts/model/llm/test_litellm.py::TestCreateClient::test_sets_sentinel_on_success PASSED
test/scripts/model/llm/test_litellm.py::TestCreateClient::test_raises_import_error_when_litellm_missing PASSED
test/scripts/model/llm/test_litellm.py::TestSendMessages::test_dispatches_to_litellm PASSED
test/scripts/model/llm/test_litellm.py::TestSendMessages::test_drop_params_always_true PASSED
test/scripts/model/llm/test_litellm.py::TestSendMessages::test_api_key_forwarded PASSED
test/scripts/model/llm/test_litellm.py::TestSendMessages::test_api_base_forwarded_when_url_set PASSED
test/scripts/model/llm/test_litellm.py::TestSendMessages::test_no_api_key_when_key_not_set PASSED
test/scripts/model/llm/test_litellm.py::TestSendMessages::test_no_api_base_when_url_not_set PASSED
test/scripts/model/llm/test_litellm.py::TestSendMessages::test_extra_params_forwarded PASSED
test/scripts/model/llm/test_litellm.py::TestSendMessages::test_raises_on_length_finish_reason PASSED
test/scripts/model/llm/test_litellm.py::TestProcessResponse::test_parses_good_score PASSED
test/scripts/model/llm/test_litellm.py::TestProcessResponse::test_parses_bad_score PASSED
13 passed in 0.50s

Full suite (excluding pyspark which isn't installed in CI without the extras): 543 passed, 27 skipped

2. Live E2E against Anthropic (via Azure Foundry):

from dingo.config.input_args import EvaluatorLLMArgs
from dingo.model.llm.base_litellm import BaseLiteLLM
from dingo.io.input import Data

class LiteLLMSmokeTest(BaseLiteLLM):
    prompt = "Score this text for quality. Return JSON with keys: score (1=good, 0=bad), reason. Text: "
    dynamic_config = EvaluatorLLMArgs(
        model="anthropic/claude-sonnet-4-6",
        key="<ANTHROPIC_API_KEY>",
    )

result = LiteLLMSmokeTest.eval(Data(content="The sky is blue and the sun rises in the east."))
# status: False
# label: ['QUALITY_GOOD']
# reason: ['Clear, factually accurate, and grammatically correct sentence.']
# metric: LiteLLMSmokeTest

Full chain verified: eval()create_client()build_messages()send_messages()litellm.completion() → Anthropic REST → process_response()EvalDetail.

3. Import syntax check: python .github/scripts/check_imports.py — 158 files checked, all pass.

4. Pin resolution: litellm>=1.35.0,<2.0 resolves cleanly (litellm 1.89.1 is current stable).

Risk / Compatibility

  • Additive only — no existing files changed except setup.py extras
  • litellm stays optional; base install (dingo-python) is unaffected
  • drop_params=True is scoped to BaseLiteLLM only; BaseOpenAI behavior is unchanged

Example usage

from dingo.config.input_args import EvaluatorLLMArgs
from dingo.model.llm.base_litellm import BaseLiteLLM
from dingo.model import Model
from dingo.io.input import Data

# Create a custom evaluator backed by any LiteLLM-supported provider
@Model.llm_register("MyLiteLLMEvaluator")
class MyLiteLLMEvaluator(BaseLiteLLM):
    prompt = "Evaluate the quality of the following text. Return JSON {score: 1|0, reason: str}. Text: "
    dynamic_config = EvaluatorLLMArgs(
        model="anthropic/claude-haiku-4-5",   # or gemini/gemini-1.5-flash, groq/llama3-8b-8192, etc.
        key="<YOUR_ANTHROPIC_API_KEY>",
    )

# Or use via SDK config (no code changes needed):
# {"name": "MyLiteLLMEvaluator", "config": {"model": "anthropic/claude-haiku-4-5", "key": "..."}}

result = MyLiteLLMEvaluator.eval(Data(content="Sample text to evaluate."))
print(result.label, result.reason)

Install:

pip install "dingo-python[litellm]"

@RheagalFire

Copy link
Copy Markdown
Author

cc @shijinpjlab @e06084

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces BaseLiteLLM, a new base class that routes LLM evaluators through LiteLLM to support multiple providers. It also adds the litellm dependency to setup.py and implements comprehensive unit tests for the new provider. The review feedback highlights two important improvements: first, create_client should initialize the embedding client to maintain compatibility with RAG-related evaluators inheriting from BaseLiteLLM; second, defensive checks should be added to handle empty response choices and prevent returning the literal string 'None' when the message content is null.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +34 to +44
def create_client(cls):
if not cls.dynamic_config.model:
raise ValueError("model cannot be empty in llm config.")
try:
import litellm # noqa: F401
except ImportError as exc:
raise ImportError(
"litellm is not installed. Run: pip install 'dingo-python[litellm]'"
) from exc
# Use cls.client as an initialisation sentinel (no real client object needed).
cls.client = True

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The BaseLiteLLM class overrides create_client but completely omits the initialization of the embedding client (cls.embedding_client and cls.embedding_model). This breaks compatibility with RAG-related evaluators that inherit from BaseLiteLLM or use embedding_config in EvaluatorLLMArgs. To ensure BaseLiteLLM is a true drop-in replacement for BaseOpenAI, we should replicate the embedding initialization logic from BaseOpenAI.create_client.

    @classmethod
    def create_client(cls):
        if not cls.dynamic_config.model:
            raise ValueError("model cannot be empty in llm config.")
        try:
            import litellm  # noqa: F401
        except ImportError as exc:
            raise ImportError(
                "litellm is not installed. Run: pip install 'dingo-python[litellm]'"
            ) from exc
        # Use cls.client as an initialisation sentinel (no real client object needed).
        cls.client = True

        # If embedding_config is configured, initialize the embedding client
        if cls.dynamic_config.embedding_config:
            from openai import OpenAI
            from dingo.config.input_args import EmbeddingConfigArgs

            embedding_cfg = cls.dynamic_config.embedding_config

            # Handle embedding_config being a dict or object
            if isinstance(embedding_cfg, dict):
                embedding_cfg = EmbeddingConfigArgs(**embedding_cfg)

            if not embedding_cfg.api_url:
                raise ValueError("embedding_config must provide api_url")

            if not embedding_cfg.model:
                raise ValueError("embedding_config must provide model")

            # Create independent Embedding client
            cls.embedding_client = OpenAI(
                api_key=embedding_cfg.key or 'dummy-key',
                base_url=embedding_cfg.api_url
            )

            cls.embedding_model = {
                'model_name': embedding_cfg.model,
                'client': cls.embedding_client
            }

Comment on lines +69 to +75
finish_reason = response.choices[0].finish_reason # type: ignore[union-attr]
if finish_reason == "length":
raise ExceedMaxTokens(
f"Exceed max tokens: {extra_params.get('max_tokens', 4000)}"
)

return str(response.choices[0].message.content) # type: ignore[union-attr]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Defensive checks should be added to handle cases where response.choices is empty or None, which can happen with certain API providers or due to content filtering. Additionally, if message.content is None, calling str(None) will return the literal string 'None', which will cause JSON parsing to fail in process_response. We should return an empty string "" instead.

Suggested change
finish_reason = response.choices[0].finish_reason # type: ignore[union-attr]
if finish_reason == "length":
raise ExceedMaxTokens(
f"Exceed max tokens: {extra_params.get('max_tokens', 4000)}"
)
return str(response.choices[0].message.content) # type: ignore[union-attr]
if not response.choices:
raise ValueError("LiteLLM returned an empty response choices list.")
choice = response.choices[0]
finish_reason = choice.finish_reason # type: ignore[union-attr]
if finish_reason == "length":
raise ExceedMaxTokens(
f"Exceed max tokens: {extra_params.get('max_tokens', 4000)}"
)
content = choice.message.content # type: ignore[union-attr]
return str(content) if content is not None else ""

@e06084

e06084 commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator

@RheagalFire thanks for add LiteLLM,pls have a look at the gemini-code-assist comment and failed CI check。

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.

2 participants