Spaces:
Running
Running
sync: 159 file da Baida98/AI@341f6c21 (2026-08-16 08:37 UTC) [deploy-all]
#45
by Baida07 - opened
- models/ai_client.py +23 -10
- tests/test_provider_profile_pool.py +61 -0
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 |
-
#
|
| 456 |
-
|
|
|
|
|
|
|
| 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:
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 479 |
continue
|
| 480 |
-
|
| 481 |
-
raise ProviderUnavailableError(
|
| 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()
|