sync: 159 file da Baida98/AI@c35825ad (2026-08-16 08:23 UTC) [deploy-all]

#44
by Baida07 - opened
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
- if selected:
284
- return selected
285
- # All profiles are quarantined: probe the earliest-to-recover profile only.
286
- return [min(providers, key=lambda p: self._breaker.get(p.identity, {}).get("open_until", 0.0))] if providers else []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 OpenRouter condividano quota e client, mentre provider diversi
356
- # restano disponibili come ensemble/fallback.
357
  pool = self._execution_pool(pool, primary_purpose)
358
- tasks = [self._fetch_one(p, messages, temperature, max_tokens) for p in pool]
359
- results = await asyncio.gather(*tasks)
360
-
361
- best_r = self._judge_best_response(results, primary_purpose)
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()