codeBOKER commited on
Commit
a54f188
·
1 Parent(s): cf6d08c

feat: replace Gemini with OpenRouter as fallback AI provider

Browse files
.env.example CHANGED
@@ -16,8 +16,8 @@ PINECONE_NAMESPACE=default
16
 
17
  GROQ_API_KEY=your-groq-api-key
18
  GROQ_MODEL=your-groq-tool-calling-model
19
- GEMINI_API_KEY=your-gemini-api-key
20
- GEMINI_MODEL=gemini-2.0-flash
21
  AI_TEMPERATURE=0.2
22
  AI_MAX_TOOL_ITERATIONS=3
23
  REQUEST_TIMEOUT_SECONDS=20
 
16
 
17
  GROQ_API_KEY=your-groq-api-key
18
  GROQ_MODEL=your-groq-tool-calling-model
19
+ OPENROUTER_API_KEY=your-openrouter-api-key
20
+ OPENROUTER_MODEL=openrouter/free
21
  AI_TEMPERATURE=0.2
22
  AI_MAX_TOOL_ITERATIONS=3
23
  REQUEST_TIMEOUT_SECONDS=20
app/ai/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
  from app.ai.orchestrator import AIOrchestrator
2
- from app.ai.providers import GeminiChatProvider, GroqChatProvider
3
 
4
- __all__ = ["AIOrchestrator", "GroqChatProvider", "GeminiChatProvider"]
 
1
  from app.ai.orchestrator import AIOrchestrator
2
+ from app.ai.providers import GroqChatProvider, OpenRouterChatProvider
3
 
4
+ __all__ = ["AIOrchestrator", "GroqChatProvider", "OpenRouterChatProvider"]
app/ai/orchestrator.py CHANGED
@@ -52,9 +52,9 @@ class AIOrchestrator:
52
  temperature=max(temperature - 0.2, 0.1),
53
  )
54
  except RetryableProviderError:
55
- logger.warning("Primary retry failed; falling back to Gemini")
56
  except RetryableProviderError:
57
- logger.warning("Primary provider failed; falling back to Gemini")
58
 
59
  return await self.fallback.chat(
60
  messages,
@@ -89,9 +89,9 @@ class AIOrchestrator:
89
  temperature=max(self.temperature - 0.2, 0.1),
90
  )
91
  except RetryableProviderError:
92
- logger.warning("Primary retry failed; falling back to Gemini")
93
  except RetryableProviderError:
94
- logger.warning("Primary provider failed; falling back to Gemini")
95
 
96
  return await self._run_provider(
97
  self.fallback,
 
52
  temperature=max(temperature - 0.2, 0.1),
53
  )
54
  except RetryableProviderError:
55
+ logger.warning("Primary retry failed; falling back to OpenRouter")
56
  except RetryableProviderError:
57
+ logger.warning("Primary provider failed; falling back to OpenRouter")
58
 
59
  return await self.fallback.chat(
60
  messages,
 
89
  temperature=max(self.temperature - 0.2, 0.1),
90
  )
91
  except RetryableProviderError:
92
+ logger.warning("Primary retry failed; falling back to OpenRouter")
93
  except RetryableProviderError:
94
+ logger.warning("Primary provider failed; falling back to OpenRouter")
95
 
96
  return await self._run_provider(
97
  self.fallback,
app/ai/providers.py CHANGED
@@ -1,4 +1,3 @@
1
- import json
2
  from typing import Any, Protocol
3
 
4
  from app.config import Settings
@@ -98,55 +97,15 @@ class GroqChatProvider(OpenAICompatibleChatProvider):
98
  )
99
 
100
 
101
- class GeminiChatProvider:
102
- name = "gemini"
103
-
104
  def __init__(self, settings: Settings) -> None:
105
- self.api_key = settings.gemini_api_key
106
- self.model = settings.gemini_model
107
- self.timeout = settings.request_timeout_seconds
108
- self._client: Any | None = None
109
-
110
- @property
111
- def client(self) -> Any:
112
- if self._client is None:
113
- from google import genai
114
-
115
- self._client = genai.Client(api_key=self.api_key)
116
- return self._client
117
-
118
- async def chat(
119
- self,
120
- messages: list[dict[str, Any]],
121
- *,
122
- tools: list[dict[str, Any]] | None = None,
123
- tool_choice: str | dict[str, Any] | None = "auto",
124
- temperature: float = 0.2,
125
- ) -> AIProviderResponse:
126
- from google.genai import types
127
-
128
- try:
129
- contents = _convert_messages_to_gemini(messages)
130
- config_kwargs: dict[str, Any] = {"temperature": temperature}
131
- if tools:
132
- config_kwargs["tools"] = _convert_tools_to_gemini(tools)
133
- if tool_choice and tool_choice != "none":
134
- config_kwargs["tool_config"] = types.ToolConfig(
135
- function_calling_config=types.FunctionCallingConfig(
136
- mode=types.FunctionCallingConfig.Mode.AUTO
137
- if tool_choice == "auto"
138
- else types.FunctionCallingConfig.Mode.ANY,
139
- )
140
- )
141
- config = types.GenerateContentConfig(**config_kwargs)
142
- response = await self.client.aio.models.generate_content(
143
- model=self.model,
144
- contents=contents,
145
- config=config,
146
- )
147
- return _parse_gemini_response(response)
148
- except Exception as exc: # noqa: BLE001
149
- raise _provider_error_from_exception(exc, self.name) from exc
150
 
151
 
152
  def _normalize_openai_message(message: Any) -> AIProviderResponse:
@@ -176,122 +135,6 @@ def _normalize_openai_message(message: Any) -> AIProviderResponse:
176
  )
177
 
178
 
179
- def _convert_tools_to_gemini(tools: list[dict[str, Any]]) -> list[Any]:
180
- from google.genai import types
181
-
182
- declarations = []
183
- for tool in tools:
184
- func = tool.get("function", {})
185
- params = func.get("parameters", {})
186
- declarations.append(
187
- types.FunctionDeclaration(
188
- name=func.get("name", ""),
189
- description=func.get("description", ""),
190
- parameters=params if params else None,
191
- )
192
- )
193
- return [types.Tool(function_declarations=declarations)]
194
-
195
-
196
- def _convert_messages_to_gemini(messages: list[dict[str, Any]]) -> list[Any]:
197
- from google.genai import types
198
-
199
- contents = []
200
- pending_tool_calls: dict[str, dict[str, Any]] = {}
201
-
202
- for msg in messages:
203
- role = msg.get("role", "user")
204
-
205
- if role == "tool":
206
- tool_call_id = msg.get("tool_call_id", "")
207
- if tool_call_id in pending_tool_calls:
208
- tc = pending_tool_calls.pop(tool_call_id)
209
- contents.append(
210
- types.Content(
211
- role="user",
212
- parts=[
213
- types.Part.from_function_response(
214
- name=tc["name"],
215
- response=json.loads(msg.get("content", "{}")),
216
- )
217
- ],
218
- )
219
- )
220
- continue
221
-
222
- parts: list[Any] = []
223
- content = msg.get("content")
224
- if content:
225
- parts.append(types.Part(text=content))
226
-
227
- tool_calls = msg.get("tool_calls") or []
228
- for tc in tool_calls:
229
- func = tc.get("function", {})
230
- args = func.get("arguments", "{}")
231
- try:
232
- parsed_args = json.loads(args)
233
- except (json.JSONDecodeError, TypeError):
234
- parsed_args = {}
235
- parts.append(
236
- types.Part.from_function_call(
237
- name=func.get("name", ""),
238
- args=parsed_args,
239
- )
240
- )
241
- pending_tool_calls[tc.get("id", "")] = {
242
- "name": func.get("name", ""),
243
- }
244
-
245
- if parts:
246
- gemini_role = "model" if role == "assistant" else "user"
247
- contents.append(types.Content(role=gemini_role, parts=parts))
248
-
249
- return contents
250
-
251
-
252
- def _parse_gemini_response(response: Any) -> AIProviderResponse:
253
- text_content = ""
254
- tool_calls: list[ToolCall] = []
255
- raw_message: dict[str, Any] = {}
256
-
257
- if not response.candidates:
258
- return AIProviderResponse(
259
- content=text_content, tool_calls=tool_calls, raw_message=raw_message
260
- )
261
-
262
- candidate = response.candidates[0]
263
- parts = candidate.content.parts if candidate.content else []
264
-
265
- for part in parts:
266
- if part.text:
267
- text_content += part.text
268
- elif part.function_call:
269
- fc = part.function_call
270
- args = dict(fc.args) if fc.args else {}
271
- tool_calls.append(
272
- ToolCall(
273
- id=fc.name,
274
- name=fc.name,
275
- arguments=json.dumps(args, ensure_ascii=False),
276
- )
277
- )
278
-
279
- raw_message["role"] = "assistant"
280
- if text_content:
281
- raw_message["content"] = text_content
282
- if tool_calls:
283
- raw_message["tool_calls"] = [
284
- {
285
- "id": tc.id,
286
- "type": "function",
287
- "function": {"name": tc.name, "arguments": tc.arguments},
288
- }
289
- for tc in tool_calls
290
- ]
291
-
292
- return AIProviderResponse(content=text_content, tool_calls=tool_calls, raw_message=raw_message)
293
-
294
-
295
  def _provider_error_from_exception(exc: Exception, provider_name: str) -> ProviderError:
296
  status_code = getattr(exc, "status_code", None)
297
  body = getattr(exc, "body", None)
 
 
1
  from typing import Any, Protocol
2
 
3
  from app.config import Settings
 
97
  )
98
 
99
 
100
+ class OpenRouterChatProvider(OpenAICompatibleChatProvider):
 
 
101
  def __init__(self, settings: Settings) -> None:
102
+ super().__init__(
103
+ api_key=settings.openrouter_api_key,
104
+ base_url="https://openrouter.ai/api/v1",
105
+ model=settings.openrouter_model,
106
+ timeout=settings.request_timeout_seconds,
107
+ name="openrouter",
108
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
 
110
 
111
  def _normalize_openai_message(message: Any) -> AIProviderResponse:
 
135
  )
136
 
137
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  def _provider_error_from_exception(exc: Exception, provider_name: str) -> ProviderError:
139
  status_code = getattr(exc, "status_code", None)
140
  body = getattr(exc, "body", None)
app/config.py CHANGED
@@ -32,8 +32,8 @@ class Settings(BaseSettings):
32
 
33
  groq_api_key: str = Field(min_length=1)
34
  groq_model: str = Field(min_length=1)
35
- gemini_api_key: str = Field(min_length=1)
36
- gemini_model: str = Field(min_length=1)
37
  ai_temperature: float = 0.2
38
  ai_max_tool_iterations: int = 3
39
  request_timeout_seconds: float = 20.0
 
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
38
  ai_max_tool_iterations: int = 3
39
  request_timeout_seconds: float = 20.0
app/services/container.py CHANGED
@@ -1,7 +1,7 @@
1
  from dataclasses import dataclass
2
 
3
  from app.ai.orchestrator import AIOrchestrator
4
- from app.ai.providers import GeminiChatProvider, GroqChatProvider
5
  from app.config import Settings
6
  from app.database.supabase import SupabaseRepository, create_supabase_client
7
  from app.services.admin_service import AdminService
@@ -30,7 +30,7 @@ class ServiceContainer:
30
  whatsapp = WhatsAppClient(settings)
31
  ai = AIOrchestrator(
32
  primary=GroqChatProvider(settings),
33
- fallback=GeminiChatProvider(settings),
34
  temperature=settings.ai_temperature,
35
  max_tool_iterations=settings.ai_max_tool_iterations,
36
  )
 
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
 
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
  )
app/services/conversation_service.py CHANGED
@@ -234,7 +234,7 @@ class ConversationService:
234
  customer_id=str(customer["id"]),
235
  sender_type="assistant",
236
  message=reply,
237
- metadata={"provider_flow": "groq_primary_gemini_fallback", "user_mode": user_mode},
238
  )
239
 
240
  if is_returning_driver:
 
234
  customer_id=str(customer["id"]),
235
  sender_type="assistant",
236
  message=reply,
237
+ metadata={"provider_flow": "groq_primary_openrouter_fallback", "user_mode": user_mode},
238
  )
239
 
240
  if is_returning_driver:
pyproject.toml CHANGED
@@ -9,7 +9,6 @@ dependencies = [
9
  "pydantic-settings>=2.4.0",
10
  "supabase>=2.6.0",
11
  "openai>=1.40.0",
12
- "google-genai>=1.0.0",
13
  "httpx>=0.27.0",
14
  "python-dotenv>=1.0.1",
15
  ]
 
9
  "pydantic-settings>=2.4.0",
10
  "supabase>=2.6.0",
11
  "openai>=1.40.0",
 
12
  "httpx>=0.27.0",
13
  "python-dotenv>=1.0.1",
14
  ]
requirements.txt CHANGED
@@ -3,7 +3,6 @@ uvicorn[standard]>=0.30.0
3
  pydantic-settings>=2.4.0
4
  supabase>=2.6.0
5
  openai>=1.40.0
6
- google-genai>=1.0.0
7
  httpx>=0.27.0
8
  python-dotenv>=1.0.1
9
 
 
3
  pydantic-settings>=2.4.0
4
  supabase>=2.6.0
5
  openai>=1.40.0
 
6
  httpx>=0.27.0
7
  python-dotenv>=1.0.1
8
 
tests/conftest.py CHANGED
@@ -20,8 +20,8 @@ def settings() -> Settings:
20
  jina_api_key="jina-secret",
21
  groq_api_key="groq-secret",
22
  groq_model="groq-tool-model",
23
- gemini_api_key="gemini-secret",
24
- gemini_model="gemini-tool-model",
25
  whatsapp_verify_token="verify-token",
26
  whatsapp_app_secret="app-secret",
27
  whatsapp_access_token="wa-token",
 
20
  jina_api_key="jina-secret",
21
  groq_api_key="groq-secret",
22
  groq_model="groq-tool-model",
23
+ openrouter_api_key="openrouter-secret",
24
+ openrouter_model="openrouter-tool-model",
25
  whatsapp_verify_token="verify-token",
26
  whatsapp_app_secret="app-secret",
27
  whatsapp_access_token="wa-token",
tests/test_ai_orchestrator.py CHANGED
@@ -31,7 +31,7 @@ class ScriptedProvider:
31
  @pytest.mark.asyncio
32
  async def test_ai_falls_back_when_primary_rate_limited():
33
  primary = ScriptedProvider("groq", [RetryableProviderError("rate limited")])
34
- fallback = ScriptedProvider("gemini", [AIProviderResponse(content="fallback reply")])
35
  registry = ToolRegistry()
36
  orchestrator = AIOrchestrator(
37
  primary=primary,
@@ -56,7 +56,7 @@ async def test_ai_retries_invalid_groq_tool_generation_before_fallback():
56
  AIProviderResponse(content="primary retry reply"),
57
  ],
58
  )
59
- fallback = ScriptedProvider("gemini", [AIProviderResponse(content="fallback")])
60
  orchestrator = AIOrchestrator(
61
  primary=primary,
62
  fallback=fallback,
@@ -142,7 +142,7 @@ async def test_ai_reports_invalid_tool_arguments_to_model():
142
  @pytest.mark.asyncio
143
  async def test_chat_falls_back_when_primary_rate_limited():
144
  primary = ScriptedProvider("groq", [RetryableProviderError("rate limited")])
145
- fallback = ScriptedProvider("gemini", [AIProviderResponse(content="fallback reply")])
146
  orchestrator = AIOrchestrator(
147
  primary=primary,
148
  fallback=fallback,
@@ -162,7 +162,7 @@ async def test_chat_falls_back_when_primary_rate_limited():
162
  @pytest.mark.asyncio
163
  async def test_chat_returns_primary_on_success():
164
  primary = ScriptedProvider("groq", [AIProviderResponse(content="primary reply")])
165
- fallback = ScriptedProvider("gemini", [AIProviderResponse(content="fallback")])
166
  orchestrator = AIOrchestrator(
167
  primary=primary,
168
  fallback=fallback,
@@ -188,7 +188,7 @@ async def test_chat_retries_invalid_tool_call_before_fallback():
188
  AIProviderResponse(content="primary retry reply"),
189
  ],
190
  )
191
- fallback = ScriptedProvider("gemini", [AIProviderResponse(content="fallback")])
192
  orchestrator = AIOrchestrator(
193
  primary=primary,
194
  fallback=fallback,
 
31
  @pytest.mark.asyncio
32
  async def test_ai_falls_back_when_primary_rate_limited():
33
  primary = ScriptedProvider("groq", [RetryableProviderError("rate limited")])
34
+ fallback = ScriptedProvider("openrouter", [AIProviderResponse(content="fallback reply")])
35
  registry = ToolRegistry()
36
  orchestrator = AIOrchestrator(
37
  primary=primary,
 
56
  AIProviderResponse(content="primary retry reply"),
57
  ],
58
  )
59
+ fallback = ScriptedProvider("openrouter", [AIProviderResponse(content="fallback")])
60
  orchestrator = AIOrchestrator(
61
  primary=primary,
62
  fallback=fallback,
 
142
  @pytest.mark.asyncio
143
  async def test_chat_falls_back_when_primary_rate_limited():
144
  primary = ScriptedProvider("groq", [RetryableProviderError("rate limited")])
145
+ fallback = ScriptedProvider("openrouter", [AIProviderResponse(content="fallback reply")])
146
  orchestrator = AIOrchestrator(
147
  primary=primary,
148
  fallback=fallback,
 
162
  @pytest.mark.asyncio
163
  async def test_chat_returns_primary_on_success():
164
  primary = ScriptedProvider("groq", [AIProviderResponse(content="primary reply")])
165
+ fallback = ScriptedProvider("openrouter", [AIProviderResponse(content="fallback")])
166
  orchestrator = AIOrchestrator(
167
  primary=primary,
168
  fallback=fallback,
 
188
  AIProviderResponse(content="primary retry reply"),
189
  ],
190
  )
191
+ fallback = ScriptedProvider("openrouter", [AIProviderResponse(content="fallback")])
192
  orchestrator = AIOrchestrator(
193
  primary=primary,
194
  fallback=fallback,
tests/test_group_message_service.py CHANGED
@@ -36,8 +36,8 @@ def settings() -> Settings:
36
  jina_api_key="jina-secret",
37
  groq_api_key="groq-secret",
38
  groq_model="groq-tool-model",
39
- gemini_api_key="gemini-secret",
40
- gemini_model="gemini-tool-model",
41
  whatsapp_verify_token="verify-token",
42
  whatsapp_app_secret="app-secret",
43
  whatsapp_access_token="wa-token",
 
36
  jina_api_key="jina-secret",
37
  groq_api_key="groq-secret",
38
  groq_model="groq-tool-model",
39
+ openrouter_api_key="openrouter-secret",
40
+ openrouter_model="openrouter-tool-model",
41
  whatsapp_verify_token="verify-token",
42
  whatsapp_app_secret="app-secret",
43
  whatsapp_access_token="wa-token",