Skip to content

Commit a3c418b

Browse files
authored
feat: add LiteLLM as LLM provider (#21)
1 parent 2e56c4f commit a3c418b

5 files changed

Lines changed: 259 additions & 0 deletions

File tree

‎ifixai/providers/litellm.py‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""LiteLLM provider for iFixAi.
2+
3+
Routes to 100+ LLM providers (OpenAI, Anthropic, Google, Azure, Bedrock,
4+
Ollama, etc.) via the litellm SDK. No proxy server needed.
5+
6+
Install: pip install ifixai[litellm]
7+
8+
See https://docs.litellm.ai/docs/providers for all supported models.
9+
"""
10+
11+
import litellm as _litellm
12+
13+
from ifixai.core.types import ChatMessage, ProviderConfig
14+
from ifixai.providers.base import (
15+
ChatProvider,
16+
ProviderConnectionError,
17+
ProviderResponseError,
18+
ProviderTimeoutError,
19+
)
20+
21+
DEFAULT_MODEL = "openai/gpt-4o"
22+
23+
24+
class LiteLLMProvider(ChatProvider):
25+
def __init__(self) -> None:
26+
pass
27+
28+
async def send_message(
29+
self,
30+
messages: list[ChatMessage],
31+
config: ProviderConfig,
32+
) -> str:
33+
model = config.model or DEFAULT_MODEL
34+
formatted_messages = [{"role": m.role, "content": m.content} for m in messages]
35+
36+
params = {
37+
"model": model,
38+
"messages": formatted_messages,
39+
"drop_params": True,
40+
"timeout": float(config.timeout),
41+
}
42+
if config.api_key:
43+
params["api_key"] = config.api_key
44+
if config.endpoint:
45+
params["api_base"] = config.endpoint
46+
47+
try:
48+
response = await _litellm.acompletion(**params)
49+
50+
content = response.choices[0].message.content
51+
if not content:
52+
raise ProviderResponseError(
53+
provider="litellm",
54+
endpoint=config.endpoint or "default",
55+
details="Empty content in response",
56+
)
57+
return content
58+
59+
except ProviderResponseError:
60+
raise
61+
except TimeoutError as exc:
62+
raise ProviderTimeoutError(
63+
provider="litellm",
64+
endpoint=config.endpoint or "default",
65+
details=str(exc),
66+
) from exc
67+
except ConnectionError as exc:
68+
raise ProviderConnectionError(
69+
provider="litellm",
70+
endpoint=config.endpoint or "default",
71+
details=str(exc),
72+
) from exc
73+
except Exception as exc:
74+
raise ProviderResponseError(
75+
provider="litellm",
76+
endpoint=config.endpoint or "default",
77+
details=str(exc),
78+
) from exc

‎ifixai/providers/resolver.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@
4343
except ImportError:
4444
OpenRouterProvider = None
4545

46+
try:
47+
from ifixai.providers.litellm import LiteLLMProvider
48+
except ImportError:
49+
LiteLLMProvider = None
50+
4651

4752
REGISTERED_PROVIDERS: tuple[str, ...] = (
4853
"http",
@@ -55,6 +60,7 @@
5560
"bedrock",
5661
"huggingface",
5762
"langchain",
63+
"litellm",
5864
)
5965

6066
_MOCK_FIXTURE_PATH = Path(__file__).parent.parent / "fixtures" / "governance" / "mock.yaml"
@@ -71,6 +77,7 @@
7177
"bedrock": BedrockProvider,
7278
"huggingface": HuggingFaceProvider,
7379
"langchain": LangChainProvider,
80+
"litellm": LiteLLMProvider,
7481
}.items()
7582
if cls is not None
7683
}
@@ -139,6 +146,7 @@ def resolve_provider(provider: Union[str, object]) -> object:
139146
"bedrock": ("AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"),
140147
"huggingface": ("HUGGINGFACE_API_TOKEN", "HF_TOKEN"),
141148
"openrouter": ("OPENROUTER_API_KEY",),
149+
"litellm": ("LITELLM_API_KEY",),
142150
}
143151

144152
_JUDGE_PREFERENCE_ORDER: tuple[str, ...] = (

‎pyproject.toml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,14 @@ anthropic = ["anthropic>=0.18"]
4343
azure = ["openai>=1.0"]
4444
bedrock = ["boto3>=1.28"]
4545
huggingface = ["huggingface-hub>=0.20"]
46+
litellm = ["litellm>=1.60.0,<2.0.0"]
4647
all = [
4748
"openai>=1.0",
4849
"google-generativeai>=0.3",
4950
"anthropic>=0.18",
5051
"boto3>=1.28",
5152
"huggingface-hub>=0.20",
53+
"litellm>=1.60.0,<2.0.0",
5254
]
5355
dev = [
5456
"ruff>=0.4",

‎tests/__init__.py‎

Whitespace-only changes.

‎tests/test_litellm_provider.py‎

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
"""Tests for LiteLLM provider."""
2+
3+
from unittest.mock import AsyncMock, MagicMock, patch
4+
5+
import pytest
6+
7+
from ifixai.core.types import ChatMessage, ProviderConfig
8+
from ifixai.providers.base import (
9+
ChatProvider,
10+
ProviderResponseError,
11+
ProviderTimeoutError,
12+
)
13+
from ifixai.providers.litellm import LiteLLMProvider
14+
15+
16+
def _make_config(**overrides):
17+
defaults = {
18+
"provider": "litellm",
19+
"model": "openai/gpt-4o",
20+
"api_key": "sk-test",
21+
}
22+
defaults.update(overrides)
23+
return ProviderConfig(**defaults)
24+
25+
26+
class TestLiteLLMProviderInit:
27+
def test_extends_chat_provider(self):
28+
assert issubclass(LiteLLMProvider, ChatProvider)
29+
30+
def test_instantiates_without_args(self):
31+
p = LiteLLMProvider()
32+
assert p is not None
33+
34+
35+
class TestSendMessage:
36+
@pytest.mark.asyncio
37+
@patch("ifixai.providers.litellm._litellm")
38+
async def test_calls_acompletion_with_drop_params(self, mock_litellm):
39+
mock_msg = MagicMock(content="test response")
40+
mock_litellm.acompletion = AsyncMock(
41+
return_value=MagicMock(choices=[MagicMock(message=mock_msg)])
42+
)
43+
44+
p = LiteLLMProvider()
45+
config = _make_config()
46+
messages = [ChatMessage(role="user", content="hello")]
47+
result = await p.send_message(messages, config)
48+
49+
assert result == "test response"
50+
kwargs = mock_litellm.acompletion.call_args.kwargs
51+
assert kwargs["drop_params"] is True
52+
assert kwargs["model"] == "openai/gpt-4o"
53+
assert kwargs["api_key"] == "sk-test"
54+
55+
@pytest.mark.asyncio
56+
@patch("ifixai.providers.litellm._litellm")
57+
async def test_omits_api_key_when_empty(self, mock_litellm):
58+
mock_msg = MagicMock(content="ok")
59+
mock_litellm.acompletion = AsyncMock(
60+
return_value=MagicMock(choices=[MagicMock(message=mock_msg)])
61+
)
62+
63+
p = LiteLLMProvider()
64+
config = _make_config(api_key="")
65+
messages = [ChatMessage(role="user", content="hi")]
66+
await p.send_message(messages, config)
67+
68+
kwargs = mock_litellm.acompletion.call_args.kwargs
69+
assert "api_key" not in kwargs
70+
71+
@pytest.mark.asyncio
72+
@patch("ifixai.providers.litellm._litellm")
73+
async def test_formats_messages_correctly(self, mock_litellm):
74+
mock_msg = MagicMock(content="ok")
75+
mock_litellm.acompletion = AsyncMock(
76+
return_value=MagicMock(choices=[MagicMock(message=mock_msg)])
77+
)
78+
79+
p = LiteLLMProvider()
80+
config = _make_config()
81+
messages = [
82+
ChatMessage(role="system", content="be helpful"),
83+
ChatMessage(role="user", content="hi"),
84+
]
85+
await p.send_message(messages, config)
86+
87+
kwargs = mock_litellm.acompletion.call_args.kwargs
88+
assert kwargs["messages"] == [
89+
{"role": "system", "content": "be helpful"},
90+
{"role": "user", "content": "hi"},
91+
]
92+
93+
@pytest.mark.asyncio
94+
@patch("ifixai.providers.litellm._litellm")
95+
async def test_uses_default_model_when_none(self, mock_litellm):
96+
mock_msg = MagicMock(content="ok")
97+
mock_litellm.acompletion = AsyncMock(
98+
return_value=MagicMock(choices=[MagicMock(message=mock_msg)])
99+
)
100+
101+
p = LiteLLMProvider()
102+
config = _make_config(model=None)
103+
messages = [ChatMessage(role="user", content="hi")]
104+
await p.send_message(messages, config)
105+
106+
kwargs = mock_litellm.acompletion.call_args.kwargs
107+
assert kwargs["model"] == "openai/gpt-4o"
108+
109+
110+
class TestEdgeCases:
111+
@pytest.mark.asyncio
112+
@patch("ifixai.providers.litellm._litellm")
113+
async def test_empty_content_raises_response_error(self, mock_litellm):
114+
mock_msg = MagicMock(content="")
115+
mock_litellm.acompletion = AsyncMock(
116+
return_value=MagicMock(choices=[MagicMock(message=mock_msg)])
117+
)
118+
119+
p = LiteLLMProvider()
120+
config = _make_config()
121+
messages = [ChatMessage(role="user", content="hi")]
122+
123+
with pytest.raises(ProviderResponseError):
124+
await p.send_message(messages, config)
125+
126+
@pytest.mark.asyncio
127+
@patch("ifixai.providers.litellm._litellm")
128+
async def test_api_error_raises_response_error(self, mock_litellm):
129+
mock_litellm.acompletion = AsyncMock(side_effect=Exception("401 Unauthorized"))
130+
131+
p = LiteLLMProvider()
132+
config = _make_config()
133+
messages = [ChatMessage(role="user", content="hi")]
134+
135+
with pytest.raises(ProviderResponseError, match="401"):
136+
await p.send_message(messages, config)
137+
138+
@pytest.mark.asyncio
139+
@patch("ifixai.providers.litellm._litellm")
140+
async def test_timeout_raises_timeout_error(self, mock_litellm):
141+
mock_litellm.acompletion = AsyncMock(side_effect=TimeoutError("timed out"))
142+
143+
p = LiteLLMProvider()
144+
config = _make_config()
145+
messages = [ChatMessage(role="user", content="hi")]
146+
147+
with pytest.raises(ProviderTimeoutError):
148+
await p.send_message(messages, config)
149+
150+
151+
class TestResolver:
152+
def test_litellm_in_registered_providers(self):
153+
from ifixai.providers.resolver import REGISTERED_PROVIDERS
154+
155+
assert "litellm" in REGISTERED_PROVIDERS
156+
157+
def test_litellm_in_provider_map(self):
158+
from ifixai.providers.resolver import _PROVIDER_MAP
159+
160+
assert "litellm" in _PROVIDER_MAP
161+
162+
def test_resolve_litellm_returns_provider(self):
163+
from ifixai.providers.resolver import resolve_provider
164+
165+
provider = resolve_provider("litellm")
166+
assert isinstance(provider, LiteLLMProvider)
167+
168+
def test_litellm_in_credential_env_vars(self):
169+
from ifixai.providers.resolver import _PROVIDER_CREDENTIAL_ENV_VARS
170+
171+
assert "litellm" in _PROVIDER_CREDENTIAL_ENV_VARS

0 commit comments

Comments
 (0)