Spaces:
Running
Running
sync: 159 file da Baida98/AI@c35825ad (2026-08-16 08:23 UTC) [deploy-all]
Browse files- models/ai_client.py +57 -12
- tests/test_provider_profile_pool.py +30 -1
models/ai_client.py
CHANGED
|
@@ -280,10 +280,31 @@ class AIClient:
|
|
| 280 |
start = self._rr_indices.get(index_key, 0)
|
| 281 |
selected.append(profiles[start % len(profiles)])
|
| 282 |
self._rr_indices[index_key] = start + 1
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
|
| 288 |
async def _fetch_one(self, provider: ProviderConfig, messages: list, temperature: float, max_tokens: int) -> Tuple[ProviderConfig, str, float]:
|
| 289 |
client = self._client_for(provider)
|
|
@@ -352,18 +373,42 @@ class AIClient:
|
|
| 352 |
raise ProviderUnavailableError([])
|
| 353 |
|
| 354 |
# 3. Un solo profilo per endpoint e richiesta: round-robin evita che
|
| 355 |
-
# profili
|
| 356 |
-
#
|
| 357 |
pool = self._execution_pool(pool, primary_purpose)
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 363 |
# S-CACHE-1: Popolamento cache asincrono
|
| 364 |
if not best_r.startswith("🔴"):
|
| 365 |
asyncio.create_task(set_cached_response(messages, best_r))
|
| 366 |
-
|
| 367 |
return best_r
|
| 368 |
|
| 369 |
def _judge_best_response(self, results: List[Tuple[ProviderConfig, str, float]], target_purpose: str) -> str:
|
|
|
|
| 280 |
start = self._rr_indices.get(index_key, 0)
|
| 281 |
selected.append(profiles[start % len(profiles)])
|
| 282 |
self._rr_indices[index_key] = start + 1
|
| 283 |
+
return selected
|
| 284 |
+
|
| 285 |
+
def _inter_provider_fallback_pool(
|
| 286 |
+
self,
|
| 287 |
+
purpose: str,
|
| 288 |
+
excluded: set[str] | None = None,
|
| 289 |
+
) -> list[ProviderConfig]:
|
| 290 |
+
"""Select one healthy profile per provider, prioritizing the target purpose.
|
| 291 |
+
|
| 292 |
+
A provider whose complete profile group is open in the circuit breaker is
|
| 293 |
+
absent from this list; the next healthy provider becomes the automatic
|
| 294 |
+
fallback. This prevents retry storms against an exhausted pool.
|
| 295 |
+
"""
|
| 296 |
+
excluded = excluded or set()
|
| 297 |
+
candidates = [
|
| 298 |
+
provider for provider in self.providers
|
| 299 |
+
if provider.name not in excluded and self._is_available(provider)
|
| 300 |
+
]
|
| 301 |
+
candidates.sort(key=lambda provider: (
|
| 302 |
+
0 if provider.purpose == purpose else 1,
|
| 303 |
+
provider.tier,
|
| 304 |
+
provider.name,
|
| 305 |
+
provider.profile,
|
| 306 |
+
))
|
| 307 |
+
return self._execution_pool(candidates, f"fallback:{purpose}")
|
| 308 |
|
| 309 |
async def _fetch_one(self, provider: ProviderConfig, messages: list, temperature: float, max_tokens: int) -> Tuple[ProviderConfig, str, float]:
|
| 310 |
client = self._client_for(provider)
|
|
|
|
| 373 |
raise ProviderUnavailableError([])
|
| 374 |
|
| 375 |
# 3. Un solo profilo per endpoint e richiesta: round-robin evita che
|
| 376 |
+
# profili condividano quota e client, mentre provider diversi restano
|
| 377 |
+
# disponibili come ensemble/fallback.
|
| 378 |
pool = self._execution_pool(pool, primary_purpose)
|
| 379 |
+
results = []
|
| 380 |
+
if pool:
|
| 381 |
+
tasks = [self._fetch_one(p, messages, temperature, max_tokens) for p in pool]
|
| 382 |
+
results = await asyncio.gather(*tasks)
|
| 383 |
+
|
| 384 |
+
valid = [result for result in results if not result[1].startswith("ERROR:") and len(result[1]) > 10]
|
| 385 |
+
if valid:
|
| 386 |
+
best_r = self._judge_best_response(results, primary_purpose)
|
| 387 |
+
else:
|
| 388 |
+
# Il pool primario è interamente in rate limit, errore auth o timeout:
|
| 389 |
+
# prova un solo profilo per ogni provider sano, in ordine di purpose/tier.
|
| 390 |
+
excluded = {provider.name for provider, _response, _latency in results}
|
| 391 |
+
fallback_pool = self._inter_provider_fallback_pool(primary_purpose, excluded)
|
| 392 |
+
fallback_results = []
|
| 393 |
+
for fallback in fallback_pool:
|
| 394 |
+
result = await self._fetch_one(fallback, messages, temperature, max_tokens)
|
| 395 |
+
fallback_results.append(result)
|
| 396 |
+
if not result[1].startswith("ERROR:") and len(result[1]) > 10:
|
| 397 |
+
_logger.info(
|
| 398 |
+
"[fleet] inter-provider fallback succeeded on %s/%s",
|
| 399 |
+
fallback.name,
|
| 400 |
+
fallback.profile,
|
| 401 |
+
)
|
| 402 |
+
best_r = result[1]
|
| 403 |
+
break
|
| 404 |
+
else:
|
| 405 |
+
failed_names = [provider.name for provider, _response, _latency in results + fallback_results]
|
| 406 |
+
raise ProviderUnavailableError(failed_names)
|
| 407 |
+
|
| 408 |
# S-CACHE-1: Popolamento cache asincrono
|
| 409 |
if not best_r.startswith("🔴"):
|
| 410 |
asyncio.create_task(set_cached_response(messages, best_r))
|
| 411 |
+
|
| 412 |
return best_r
|
| 413 |
|
| 414 |
def _judge_best_response(self, results: List[Tuple[ProviderConfig, str, float]], target_purpose: str) -> str:
|
tests/test_provider_profile_pool.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
import json
|
| 2 |
import os
|
| 3 |
import unittest
|
| 4 |
-
from unittest.mock import patch
|
| 5 |
|
| 6 |
from models.ai_client import AIClient, ProviderConfig, _PROVIDER_DEFS
|
| 7 |
|
|
@@ -55,6 +55,15 @@ class ProviderProfilePoolTests(unittest.TestCase):
|
|
| 55 |
self.assertEqual([p.profile for p in profiles], ["primary", "backup"])
|
| 56 |
self.assertNotIn("database-key", [p.api_key for p in profiles])
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
def test_profiles_have_distinct_client_cache_entries(self):
|
| 59 |
client = AIClient()
|
| 60 |
first, second = self._profiles()[:2]
|
|
@@ -75,5 +84,25 @@ class ProviderProfilePoolTests(unittest.TestCase):
|
|
| 75 |
self.assertNotEqual(selected[0].profile, "b")
|
| 76 |
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
if __name__ == "__main__":
|
| 79 |
unittest.main()
|
|
|
|
| 1 |
import json
|
| 2 |
import os
|
| 3 |
import unittest
|
| 4 |
+
from unittest.mock import AsyncMock, patch
|
| 5 |
|
| 6 |
from models.ai_client import AIClient, ProviderConfig, _PROVIDER_DEFS
|
| 7 |
|
|
|
|
| 55 |
self.assertEqual([p.profile for p in profiles], ["primary", "backup"])
|
| 56 |
self.assertNotIn("database-key", [p.api_key for p in profiles])
|
| 57 |
|
| 58 |
+
def test_inter_provider_pool_excludes_exhausted_provider(self):
|
| 59 |
+
client = AIClient()
|
| 60 |
+
openrouter = ProviderConfig(name="openrouter", api_key="or", base_url="https://openrouter.ai/api/v1", profile="a", purpose="coding", tier=1)
|
| 61 |
+
groq = ProviderConfig(name="groq", api_key="groq", base_url="https://api.groq.com/openai/v1", profile="a", purpose="reasoning", tier=0)
|
| 62 |
+
client.providers = [openrouter, groq]
|
| 63 |
+
client._breaker[openrouter.identity] = {"failures": 2, "open_until": 10**12}
|
| 64 |
+
selected = client._inter_provider_fallback_pool("coding", {"openrouter"})
|
| 65 |
+
self.assertEqual([provider.name for provider in selected], ["groq"])
|
| 66 |
+
|
| 67 |
def test_profiles_have_distinct_client_cache_entries(self):
|
| 68 |
client = AIClient()
|
| 69 |
first, second = self._profiles()[:2]
|
|
|
|
| 84 |
self.assertNotEqual(selected[0].profile, "b")
|
| 85 |
|
| 86 |
|
| 87 |
+
class InterProviderFallbackChatTests(unittest.IsolatedAsyncioTestCase):
|
| 88 |
+
async def test_chat_falls_back_when_primary_pool_returns_errors(self):
|
| 89 |
+
client = AIClient()
|
| 90 |
+
openrouter = ProviderConfig(name="openrouter", api_key="or", base_url="https://openrouter.ai/api/v1", profile="a", purpose="coding", tier=1)
|
| 91 |
+
groq = ProviderConfig(name="groq", api_key="groq", base_url="https://api.groq.com/openai/v1", profile="a", purpose="reasoning", tier=0)
|
| 92 |
+
client.providers = [openrouter, groq]
|
| 93 |
+
|
| 94 |
+
async def fake_fetch(provider, messages, temperature, max_tokens):
|
| 95 |
+
if provider.name == "openrouter":
|
| 96 |
+
return provider, "ERROR: HTTP 429 rate limit", 0.0
|
| 97 |
+
return provider, "fallback answer from healthy provider", 0.2
|
| 98 |
+
|
| 99 |
+
with patch("models.ai_client.get_cached_response", new=AsyncMock(return_value=None)), \
|
| 100 |
+
patch("models.ai_client.set_cached_response", new=AsyncMock()), \
|
| 101 |
+
patch.object(client, "_fetch_one", side_effect=fake_fetch):
|
| 102 |
+
answer = await client.chat([{"role": "user", "content": "write code"}])
|
| 103 |
+
|
| 104 |
+
self.assertEqual(answer, "fallback answer from healthy provider")
|
| 105 |
+
|
| 106 |
+
|
| 107 |
if __name__ == "__main__":
|
| 108 |
unittest.main()
|