codeBOKER commited on
Commit
87c0663
·
1 Parent(s): 64075d7

Replace AIOrchestrator with LiteLLM Router for multi-provider fallback

Browse files

- Add LiteLLMOrchestration class using litellm.Router
- Configure 4-provider fallback chain: Groq -> HF1 -> HF2 -> OpenRouter
- Add HuggingFace provider settings (hf_api_key, hf_api_key_2, hf_model)
- Zero retries for immediate fallback between providers
- Add litellm dependency

app/ai/__init__.py CHANGED
@@ -1,4 +1,5 @@
1
  from app.ai.orchestrator import AIOrchestrator
2
  from app.ai.providers import GroqChatProvider, OpenRouterChatProvider
 
3
 
4
- __all__ = ["AIOrchestrator", "GroqChatProvider", "OpenRouterChatProvider"]
 
1
  from app.ai.orchestrator import AIOrchestrator
2
  from app.ai.providers import GroqChatProvider, OpenRouterChatProvider
3
+ from app.ai.router import LiteLLMOrchestration
4
 
5
+ __all__ = ["AIOrchestrator", "GroqChatProvider", "OpenRouterChatProvider", "LiteLLMOrchestration"]
app/ai/router.py ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import logging
3
+ from typing import Any
4
+
5
+ import litellm
6
+
7
+ from app.models.domain import AIProviderResponse, ToolCall
8
+ from app.tools.registry import ToolRegistry
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class LiteLLMOrchestration:
14
+ """Provides AI orchestration using LiteLLM Router for automatic API management"""
15
+
16
+ def __init__(
17
+ self,
18
+ groq_api_key: str,
19
+ groq_model: str,
20
+ hf_api_key: str,
21
+ hf_api_key_2: str,
22
+ hf_model: str,
23
+ openrouter_api_key: str,
24
+ openrouter_model: str,
25
+ max_tool_iterations: int = 3,
26
+ temperature: float = 0.2,
27
+ ) -> None:
28
+ self.name = "litellm-orchestration"
29
+ self.groq_api_key = groq_api_key
30
+ self.groq_model = groq_model
31
+ self.hf_api_key = hf_api_key
32
+ self.hf_api_key_2 = hf_api_key_2
33
+ self.hf_model = hf_model
34
+ self.openrouter_api_key = openrouter_api_key
35
+ self.openrouter_model = openrouter_model
36
+ self.max_tool_iterations = max_tool_iterations
37
+ self.temperature = temperature
38
+ self._initialized = False
39
+
40
+ @staticmethod
41
+ def _litellm_model(model: str, provider: str) -> str:
42
+ """Ensure model name has the litellm provider prefix.
43
+
44
+ litellm strips the provider prefix before sending to the API,
45
+ so 'groq/openai/gpt-oss-120b' sends 'openai/gpt-oss-120b' to Groq.
46
+ """
47
+ if model.startswith(f"{provider}/"):
48
+ return model
49
+ return f"{provider}/{model}"
50
+
51
+ async def _ensure_initialized(self):
52
+ if not self._initialized:
53
+ self.router = litellm.Router(
54
+ model_list=[
55
+ {
56
+ "model_name": "groq",
57
+ "litellm_params": {
58
+ "model": self._litellm_model(self.groq_model, "groq"),
59
+ "api_key": self.groq_api_key,
60
+ "api_base": "https://api.groq.com/openai/v1",
61
+ },
62
+ },
63
+ {
64
+ "model_name": "hf1",
65
+ "litellm_params": {
66
+ "model": self._litellm_model(self.hf_model, "huggingface"),
67
+ "api_key": self.hf_api_key,
68
+ "api_base": "https://router.huggingface.co/v1",
69
+ },
70
+ },
71
+ {
72
+ "model_name": "hf2",
73
+ "litellm_params": {
74
+ "model": self._litellm_model(self.hf_model, "huggingface"),
75
+ "api_key": self.hf_api_key_2,
76
+ "api_base": "https://router.huggingface.co/v1",
77
+ },
78
+ },
79
+ {
80
+ "model_name": "openrouter",
81
+ "litellm_params": {
82
+ "model": self._litellm_model(self.openrouter_model, "openrouter"),
83
+ "api_key": self.openrouter_api_key,
84
+ "api_base": "https://openrouter.ai/api/v1",
85
+ },
86
+ },
87
+ ],
88
+ fallbacks=[{"groq": ["hf1", "hf2", "openrouter"]}],
89
+ num_retries=0,
90
+ retry_policy={
91
+ "TimeoutErrorRetries": 0,
92
+ "RateLimitErrorRetries": 0,
93
+ "InternalServerErrorRetries": 0,
94
+ },
95
+ routing_strategy="latency-based-routing",
96
+ set_verbose=False,
97
+ )
98
+ self._initialized = True
99
+
100
+ async def chat(
101
+ self,
102
+ *,
103
+ messages: list[dict[str, Any]],
104
+ tools: list[dict[str, Any]] | None = None,
105
+ temperature: float | None = None,
106
+ tool_choice: str | None = "auto",
107
+ ) -> AIProviderResponse:
108
+ if temperature is None:
109
+ temperature = self.temperature
110
+
111
+ await self._ensure_initialized()
112
+
113
+ kwargs = {
114
+ "messages": messages,
115
+ "model": "groq",
116
+ "temperature": temperature,
117
+ }
118
+
119
+ if tools:
120
+ kwargs["tools"] = tools
121
+ kwargs["tool_choice"] = tool_choice
122
+
123
+ try:
124
+ response = await self.router.acompletion(**kwargs)
125
+ return self._adapt_response(response)
126
+ except Exception as exc:
127
+ logger.error("LiteLLM Router error: %s", exc)
128
+ raise RuntimeError(f"AI request failed: {exc}") from exc
129
+
130
+ async def generate_reply(
131
+ self,
132
+ *,
133
+ messages: list[dict[str, Any]],
134
+ tools: list[dict[str, Any]],
135
+ registry: ToolRegistry,
136
+ ) -> str:
137
+ working_messages = [dict(msg) for msg in messages]
138
+
139
+ for _ in range(self.max_tool_iterations + 1):
140
+ response = await self.chat(
141
+ messages=working_messages,
142
+ tools=tools,
143
+ temperature=self.temperature,
144
+ )
145
+
146
+ if not response.tool_calls:
147
+ content = (response.content or "").strip()
148
+ if content:
149
+ return content
150
+ continue
151
+
152
+ working_messages.append(self._assistant_tool_message(response))
153
+ for tool_call in response.tool_calls:
154
+ result = await self._execute_tool_call(registry, tool_call)
155
+ if result.suppress_llm_reply:
156
+ return result.error or ""
157
+ working_messages.append(
158
+ {
159
+ "role": "tool",
160
+ "tool_call_id": tool_call.id,
161
+ "name": tool_call.name,
162
+ "content": json.dumps(result.to_payload(), ensure_ascii=False),
163
+ }
164
+ )
165
+
166
+ return (
167
+ "I found that this request needs extra checking. "
168
+ "A support team member will follow up with you shortly."
169
+ )
170
+
171
+ def _adapt_response(self, response: Any) -> AIProviderResponse:
172
+ raw_message: dict[str, Any] = {}
173
+
174
+ if hasattr(response, "choices") and response.choices:
175
+ choice = response.choices[0]
176
+ if hasattr(choice, "message") and choice.message:
177
+ if hasattr(choice.message, "model_dump"):
178
+ raw_message = choice.message.model_dump(exclude_none=True)
179
+ elif isinstance(choice.message, dict):
180
+ raw_message = choice.message
181
+ else:
182
+ raw_message = {
183
+ "content": getattr(choice.message, "content", None),
184
+ "tool_calls": getattr(choice.message, "tool_calls", None),
185
+ }
186
+
187
+ tool_calls = []
188
+ for tool_call in raw_message.get("tool_calls") or []:
189
+ if hasattr(tool_call, "function"):
190
+ function = tool_call.function
191
+ name = function.name
192
+ arguments = function.arguments or "{}"
193
+ id_val = getattr(tool_call, "id", None) or f"tool-call-{name}"
194
+ else:
195
+ function = tool_call.get("function") or {}
196
+ name = function.get("name", "")
197
+ arguments = function.get("arguments") or "{}"
198
+ id_val = tool_call.get("id") or f"tool-call-{name}"
199
+
200
+ tool_calls.append(
201
+ ToolCall(
202
+ id=id_val,
203
+ name=name,
204
+ arguments=arguments,
205
+ )
206
+ )
207
+
208
+ return AIProviderResponse(
209
+ content=raw_message.get("content"),
210
+ tool_calls=tool_calls,
211
+ raw_message=raw_message,
212
+ )
213
+
214
+ @staticmethod
215
+ def _assistant_tool_message(response: AIProviderResponse) -> dict[str, Any]:
216
+ if response.raw_message:
217
+ return response.raw_message
218
+ return {
219
+ "role": "assistant",
220
+ "content": response.content,
221
+ "tool_calls": [
222
+ {
223
+ "id": tool_call.id,
224
+ "type": "function",
225
+ "function": {"name": tool_call.name, "arguments": tool_call.arguments},
226
+ }
227
+ for tool_call in response.tool_calls
228
+ ],
229
+ }
230
+
231
+ @staticmethod
232
+ async def _execute_tool_call(
233
+ registry: ToolRegistry, tool_call: ToolCall
234
+ ) -> Any:
235
+ try:
236
+ arguments = json.loads(tool_call.arguments or "{}")
237
+ if not isinstance(arguments, dict):
238
+ raise ValueError("Tool arguments must be a JSON object")
239
+ except (json.JSONDecodeError, ValueError) as exc:
240
+ from app.models.domain import ToolResult
241
+
242
+ return ToolResult(
243
+ ok=False, data={}, error=f"Invalid tool arguments: {exc}"
244
+ )
245
+
246
+ return await registry.execute(tool_call.name, arguments)
app/config.py CHANGED
@@ -32,6 +32,9 @@ class Settings(BaseSettings):
32
 
33
  groq_api_key: str = Field(min_length=1)
34
  groq_model: str = Field(min_length=1)
 
 
 
35
  openrouter_api_key: str = Field(min_length=1)
36
  openrouter_model: str = Field(min_length=1)
37
  ai_temperature: float = 0.2
 
32
 
33
  groq_api_key: str = Field(min_length=1)
34
  groq_model: str = Field(min_length=1)
35
+ hf_api_key: str = Field(min_length=1)
36
+ hf_api_key_2: str = Field(min_length=1)
37
+ hf_model: str = Field(min_length=1)
38
  openrouter_api_key: str = Field(min_length=1)
39
  openrouter_model: str = Field(min_length=1)
40
  ai_temperature: float = 0.2
app/services/container.py CHANGED
@@ -1,7 +1,6 @@
1
  from dataclasses import dataclass
2
 
3
- from app.ai.orchestrator import AIOrchestrator
4
- from app.ai.providers import GroqChatProvider, OpenRouterChatProvider
5
  from app.config import Settings
6
  from app.database.supabase import SupabaseRepository, create_supabase_client
7
  from app.services.admin_service import AdminService
@@ -17,7 +16,7 @@ class ServiceContainer:
17
  repository: SupabaseRepository
18
  embeddings: JinaEmbeddingService
19
  whatsapp: WhatsAppClient
20
- ai: AIOrchestrator
21
  conversation: ConversationService
22
  group_message: GroupMessageService
23
  admin: AdminService
@@ -28,12 +27,19 @@ class ServiceContainer:
28
  repository = SupabaseRepository(supabase_client)
29
  embeddings = JinaEmbeddingService(settings)
30
  whatsapp = WhatsAppClient(settings)
31
- ai = AIOrchestrator(
32
- primary=GroqChatProvider(settings),
33
- fallback=OpenRouterChatProvider(settings),
34
- temperature=settings.ai_temperature,
 
 
 
 
 
35
  max_tool_iterations=settings.ai_max_tool_iterations,
 
36
  )
 
37
  conversation = ConversationService(
38
  repository=repository,
39
  embeddings=embeddings,
 
1
  from dataclasses import dataclass
2
 
3
+ from app.ai.router import LiteLLMOrchestration
 
4
  from app.config import Settings
5
  from app.database.supabase import SupabaseRepository, create_supabase_client
6
  from app.services.admin_service import AdminService
 
16
  repository: SupabaseRepository
17
  embeddings: JinaEmbeddingService
18
  whatsapp: WhatsAppClient
19
+ ai: LiteLLMOrchestration
20
  conversation: ConversationService
21
  group_message: GroupMessageService
22
  admin: AdminService
 
27
  repository = SupabaseRepository(supabase_client)
28
  embeddings = JinaEmbeddingService(settings)
29
  whatsapp = WhatsAppClient(settings)
30
+
31
+ ai = LiteLLMOrchestration(
32
+ groq_api_key=settings.groq_api_key,
33
+ groq_model=settings.groq_model,
34
+ hf_api_key=settings.hf_api_key,
35
+ hf_api_key_2=settings.hf_api_key_2,
36
+ hf_model=settings.hf_model,
37
+ openrouter_api_key=settings.openrouter_api_key,
38
+ openrouter_model=settings.openrouter_model,
39
  max_tool_iterations=settings.ai_max_tool_iterations,
40
+ temperature=settings.ai_temperature,
41
  )
42
+
43
  conversation = ConversationService(
44
  repository=repository,
45
  embeddings=embeddings,
app/services/conversation_service.py CHANGED
@@ -3,6 +3,7 @@ from pathlib import Path
3
  from typing import Any
4
 
5
  from app.ai.orchestrator import AIOrchestrator
 
6
  from app.ai.tool_schemas import _TOOLS_BY_MODE, get_tool_schemas
7
  from app.config import Settings
8
  from app.database.supabase import SupabaseRepository
@@ -30,7 +31,7 @@ class ConversationService:
30
  repository: SupabaseRepository,
31
  embeddings: JinaEmbeddingService,
32
  whatsapp: WhatsAppClient,
33
- ai: AIOrchestrator,
34
  settings: Settings,
35
  system_prompt_path: Path | None = None,
36
  ) -> None:
 
3
  from typing import Any
4
 
5
  from app.ai.orchestrator import AIOrchestrator
6
+ from app.ai.router import LiteLLMOrchestration
7
  from app.ai.tool_schemas import _TOOLS_BY_MODE, get_tool_schemas
8
  from app.config import Settings
9
  from app.database.supabase import SupabaseRepository
 
31
  repository: SupabaseRepository,
32
  embeddings: JinaEmbeddingService,
33
  whatsapp: WhatsAppClient,
34
+ ai: AIOrchestrator | LiteLLMOrchestration,
35
  settings: Settings,
36
  system_prompt_path: Path | None = None,
37
  ) -> None:
app/services/group_message_service.py CHANGED
@@ -6,6 +6,7 @@ from pathlib import Path
6
  from typing import Any
7
 
8
  from app.ai.orchestrator import AIOrchestrator
 
9
  from app.config import Settings
10
  from app.database.supabase import SupabaseRepository
11
  from app.models.domain import ExtractedTrip, WhatsAppInboundMessage
@@ -25,7 +26,7 @@ class GroupMessageService:
25
  *,
26
  repository: SupabaseRepository,
27
  embeddings: JinaEmbeddingService,
28
- ai: AIOrchestrator,
29
  settings: Settings,
30
  ) -> None:
31
  self.repository = repository
 
6
  from typing import Any
7
 
8
  from app.ai.orchestrator import AIOrchestrator
9
+ from app.ai.router import LiteLLMOrchestration
10
  from app.config import Settings
11
  from app.database.supabase import SupabaseRepository
12
  from app.models.domain import ExtractedTrip, WhatsAppInboundMessage
 
26
  *,
27
  repository: SupabaseRepository,
28
  embeddings: JinaEmbeddingService,
29
+ ai: AIOrchestrator | LiteLLMOrchestration,
30
  settings: Settings,
31
  ) -> None:
32
  self.repository = repository
requirements.txt CHANGED
@@ -5,6 +5,7 @@ supabase>=2.6.0
5
  openai>=1.40.0
6
  httpx>=0.27.0
7
  python-dotenv>=1.0.1
 
8
 
9
  # Development and verification
10
  pytest>=8.3.0
 
5
  openai>=1.40.0
6
  httpx>=0.27.0
7
  python-dotenv>=1.0.1
8
+ litellm>=1.93.0
9
 
10
  # Development and verification
11
  pytest>=8.3.0