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

#45
by Baida07 - opened
models/ai_client.py CHANGED
@@ -452,33 +452,46 @@ class AIClient:
452
  except Exception as exc:
453
  _logger.debug("Streaming fleet expansion skipped: %s", type(exc).__name__)
454
 
455
- # Nello streaming proviamo un profilo ruotato per endpoint, poi i fallback runtime.
456
- providers = self._execution_pool(providers, "stream")
 
 
457
  for provider in providers:
 
458
  client = self._client_for(provider)
 
459
  try:
460
  stream = await asyncio.to_thread(
461
  client.chat.completions.create,
462
- model=provider.default_model,
463
- messages=messages,
464
- temperature=temperature,
465
- max_tokens=max_tokens,
466
  stream=True,
467
  )
468
  iterator = iter(stream)
469
  while True:
470
  chunk = await asyncio.to_thread(next, iterator, None)
471
- if chunk is None: break
 
472
  if chunk.choices and chunk.choices[0].delta.content:
 
473
  yield chunk.choices[0].delta.content
474
  self._record_success(provider)
475
  return
476
  except Exception as e:
477
  self._record_failure(provider, e)
478
- _logger.warning(f"Streaming fallito su {provider.name}/{provider.profile}: {e}")
 
 
 
 
 
 
 
479
  continue
480
-
481
- raise ProviderUnavailableError([provider.name for provider in providers])
482
 
483
 
484
 
 
452
  except Exception as exc:
453
  _logger.debug("Streaming fleet expansion skipped: %s", type(exc).__name__)
454
 
455
+ # Un profilo sano per provider: se l’intero pool primario è in rate
456
+ # limit, il fallback passa automaticamente al provider successivo.
457
+ providers = self._inter_provider_fallback_pool("stream")
458
+ attempted: list[str] = []
459
  for provider in providers:
460
+ attempted.append(provider.name)
461
  client = self._client_for(provider)
462
+ emitted = False
463
  try:
464
  stream = await asyncio.to_thread(
465
  client.chat.completions.create,
466
+ model=provider.default_model,
467
+ messages=messages,
468
+ temperature=temperature,
469
+ max_tokens=max_tokens,
470
  stream=True,
471
  )
472
  iterator = iter(stream)
473
  while True:
474
  chunk = await asyncio.to_thread(next, iterator, None)
475
+ if chunk is None:
476
+ break
477
  if chunk.choices and chunk.choices[0].delta.content:
478
+ emitted = True
479
  yield chunk.choices[0].delta.content
480
  self._record_success(provider)
481
  return
482
  except Exception as e:
483
  self._record_failure(provider, e)
484
+ _logger.warning(
485
+ "Streaming fallito su %s/%s (emitted=%s): %s",
486
+ provider.name, provider.profile, emitted, e,
487
+ )
488
+ # Retry solo prima del primo chunk: dopo output parziale un
489
+ # retry produrrebbe testo duplicato o una risposta incoerente.
490
+ if emitted:
491
+ raise
492
  continue
493
+
494
+ raise ProviderUnavailableError(attempted)
495
 
496
 
497
 
tests/test_provider_profile_pool.py CHANGED
@@ -1,5 +1,6 @@
1
  import json
2
  import os
 
3
  import unittest
4
  from unittest.mock import AsyncMock, patch
5
 
@@ -104,5 +105,65 @@ class InterProviderFallbackChatTests(unittest.IsolatedAsyncioTestCase):
104
  self.assertEqual(answer, "fallback answer from healthy provider")
105
 
106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  if __name__ == "__main__":
108
  unittest.main()
 
1
  import json
2
  import os
3
+ import types
4
  import unittest
5
  from unittest.mock import AsyncMock, patch
6
 
 
105
  self.assertEqual(answer, "fallback answer from healthy provider")
106
 
107
 
108
+ class StreamingFallbackTests(unittest.IsolatedAsyncioTestCase):
109
+ @staticmethod
110
+ def _chunk(text):
111
+ return types.SimpleNamespace(
112
+ choices=[types.SimpleNamespace(
113
+ delta=types.SimpleNamespace(content=text),
114
+ )],
115
+ )
116
+
117
+ async def test_stream_retries_next_provider_before_first_chunk(self):
118
+ client = AIClient()
119
+ first = ProviderConfig(name="openrouter", api_key="or", base_url="https://openrouter.ai/api/v1", profile="a", purpose="coding", tier=1)
120
+ second = ProviderConfig(name="groq", api_key="groq", base_url="https://api.groq.com/openai/v1", profile="a", purpose="reasoning", tier=0)
121
+ client.providers = [first, second]
122
+
123
+ class FakeCompletions:
124
+ def __init__(self, provider):
125
+ self.provider = provider
126
+ def create(self, **_kwargs):
127
+ if self.provider == "openrouter":
128
+ raise RuntimeError("HTTP 429 rate limit")
129
+ return iter([StreamingFallbackTests._chunk("healthy "), StreamingFallbackTests._chunk("stream")])
130
+
131
+ def fake_client(provider):
132
+ return types.SimpleNamespace(chat=types.SimpleNamespace(completions=FakeCompletions(provider.name)))
133
+
134
+ with patch.object(client, "_client_for", side_effect=fake_client):
135
+ output = [part async for part in client.stream_chat([{"role": "user", "content": "hello"}])]
136
+
137
+ self.assertEqual(output, ["healthy ", "stream"])
138
+
139
+ async def test_stream_does_not_retry_after_partial_output(self):
140
+ client = AIClient()
141
+ first = ProviderConfig(name="openrouter", api_key="or", base_url="https://openrouter.ai/api/v1", profile="a", purpose="coding", tier=1)
142
+ second = ProviderConfig(name="groq", api_key="groq", base_url="https://api.groq.com/openai/v1", profile="a", purpose="reasoning", tier=2)
143
+ client.providers = [first, second]
144
+ calls = []
145
+
146
+ class FakeCompletions:
147
+ def __init__(self, provider):
148
+ self.provider = provider
149
+ def create(self, **_kwargs):
150
+ calls.append(self.provider)
151
+ if self.provider == "openrouter":
152
+ def broken_stream():
153
+ yield StreamingFallbackTests._chunk("partial")
154
+ raise RuntimeError("stream disconnected")
155
+ return broken_stream()
156
+ return iter([StreamingFallbackTests._chunk("should not run")])
157
+
158
+ def fake_client(provider):
159
+ return types.SimpleNamespace(chat=types.SimpleNamespace(completions=FakeCompletions(provider.name)))
160
+
161
+ with patch.object(client, "_client_for", side_effect=fake_client):
162
+ with self.assertRaises(RuntimeError):
163
+ _ = [part async for part in client.stream_chat([{"role": "user", "content": "hello"}])]
164
+
165
+ self.assertEqual(calls, ["openrouter"])
166
+
167
+
168
  if __name__ == "__main__":
169
  unittest.main()