Spaces:
Running
Running
sync: 159 file da Baida98/AI@a6de7873 (2026-08-16 07:48 UTC) [deploy-all] (#42)
Browse files- sync: 159 file da Baida98/AI@a6de7873 (2026-08-16 07:48 UTC) [deploy-all] (b05c64a6c34d62e5e6a2db6133d442399495931f)
- .env.example +3 -0
- api/state.py +3 -0
- main.py +3 -0
- models/ai_client.py +107 -13
- models/role_router.py +85 -31
- tests/test_provider_profile_pool.py +63 -0
.env.example
CHANGED
|
@@ -47,6 +47,9 @@ RAILWAY_PROJECT_ID_E=YOUR_RAILWAY_PROJECT_ID_E
|
|
| 47 |
# Configurare nei Secrets del provider hosting (HF/Railway)
|
| 48 |
GROQ_API_KEY=
|
| 49 |
OPENROUTER_API_KEY=
|
|
|
|
|
|
|
|
|
|
| 50 |
GEMINI_API_KEY=
|
| 51 |
NVIDIA_API_KEY=
|
| 52 |
|
|
|
|
| 47 |
# Configurare nei Secrets del provider hosting (HF/Railway)
|
| 48 |
GROQ_API_KEY=
|
| 49 |
OPENROUTER_API_KEY=
|
| 50 |
+
# Pool opzionale: JSON senza loggare le chiavi. Ogni profilo deve avere profile e api_key.
|
| 51 |
+
# Esempio: OPENROUTER_PROFILES_JSON=[{"profile":"primary","api_key":"..."},{"profile":"backup","api_key":"..."}]
|
| 52 |
+
OPENROUTER_PROFILES_JSON=
|
| 53 |
GEMINI_API_KEY=
|
| 54 |
NVIDIA_API_KEY=
|
| 55 |
|
api/state.py
CHANGED
|
@@ -119,6 +119,9 @@ SENSITIVE = {
|
|
| 119 |
'VAULT_KEY', 'INTERNAL_TOKEN', 'DEPLOY_SECRET', 'WEBHOOK_TOKEN',
|
| 120 |
'TERMINAL_SECRET', 'EXEC_TOKEN', 'VITE_INTERNAL_TOKEN', 'VITE_TERMINAL_SECRET',
|
| 121 |
'VITE_OPENROUTER_API_KEY', 'VITE_HF_TOKEN', 'VITE_GROQ_API_KEY',
|
|
|
|
|
|
|
|
|
|
| 122 |
'GH_PAGES_TOKEN', 'VERCEL_TOKEN',
|
| 123 |
}
|
| 124 |
|
|
|
|
| 119 |
'VAULT_KEY', 'INTERNAL_TOKEN', 'DEPLOY_SECRET', 'WEBHOOK_TOKEN',
|
| 120 |
'TERMINAL_SECRET', 'EXEC_TOKEN', 'VITE_INTERNAL_TOKEN', 'VITE_TERMINAL_SECRET',
|
| 121 |
'VITE_OPENROUTER_API_KEY', 'VITE_HF_TOKEN', 'VITE_GROQ_API_KEY',
|
| 122 |
+
'OPENROUTER_PROFILES_JSON', 'GROQ_PROFILES_JSON', 'CEREBRAS_PROFILES_JSON',
|
| 123 |
+
'SAMBANOVA_PROFILES_JSON', 'GEMINI_PROFILES_JSON', 'NVIDIA_PROFILES_JSON',
|
| 124 |
+
'HF_ROUTER_PROFILES_JSON',
|
| 125 |
'GH_PAGES_TOKEN', 'VERCEL_TOKEN',
|
| 126 |
}
|
| 127 |
|
main.py
CHANGED
|
@@ -57,6 +57,9 @@ async def _run_auto_migration():
|
|
| 57 |
'VAULT_KEY', 'INTERNAL_TOKEN', 'DEPLOY_SECRET', 'WEBHOOK_TOKEN',
|
| 58 |
'TERMINAL_SECRET', 'EXEC_TOKEN', 'VITE_INTERNAL_TOKEN', 'VITE_TERMINAL_SECRET',
|
| 59 |
'VITE_OPENROUTER_API_KEY', 'VITE_HF_TOKEN', 'VITE_GROQ_API_KEY',
|
|
|
|
|
|
|
|
|
|
| 60 |
'GH_PAGES_TOKEN', 'VERCEL_TOKEN'
|
| 61 |
]
|
| 62 |
|
|
|
|
| 57 |
'VAULT_KEY', 'INTERNAL_TOKEN', 'DEPLOY_SECRET', 'WEBHOOK_TOKEN',
|
| 58 |
'TERMINAL_SECRET', 'EXEC_TOKEN', 'VITE_INTERNAL_TOKEN', 'VITE_TERMINAL_SECRET',
|
| 59 |
'VITE_OPENROUTER_API_KEY', 'VITE_HF_TOKEN', 'VITE_GROQ_API_KEY',
|
| 60 |
+
'OPENROUTER_PROFILES_JSON', 'GROQ_PROFILES_JSON', 'CEREBRAS_PROFILES_JSON',
|
| 61 |
+
'SAMBANOVA_PROFILES_JSON', 'GEMINI_PROFILES_JSON', 'NVIDIA_PROFILES_JSON',
|
| 62 |
+
'HF_ROUTER_PROFILES_JSON',
|
| 63 |
'GH_PAGES_TOKEN', 'VERCEL_TOKEN'
|
| 64 |
]
|
| 65 |
|
models/ai_client.py
CHANGED
|
@@ -14,6 +14,7 @@ sempre vuoto in produzione (ogni chiamata falliva silenziosamente con
|
|
| 14 |
from __future__ import annotations
|
| 15 |
|
| 16 |
import asyncio
|
|
|
|
| 17 |
import os
|
| 18 |
import time as _time_mod
|
| 19 |
from dataclasses import dataclass
|
|
@@ -49,6 +50,11 @@ class ProviderConfig:
|
|
| 49 |
purpose: str = "reasoning"
|
| 50 |
profile: str = "general"
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
# Definizione statica dei provider LLM realmente attivi nel progetto.
|
| 53 |
# base_url punta sempre all'endpoint OpenAI-compatible ufficiale del provider
|
| 54 |
# (nessun proxy CF Worker qui: questo client gira lato backend Python, non browser).
|
|
@@ -69,9 +75,12 @@ _PROVIDER_DEFS = [
|
|
| 69 |
class AIClient:
|
| 70 |
def __init__(self) -> None:
|
| 71 |
self.providers = self._load_providers()
|
| 72 |
-
self._client_cache: dict[str, OpenAI] = {}
|
| 73 |
-
#
|
| 74 |
self._rr_indices: dict[str, int] = {}
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
def _load_providers(self) -> list[ProviderConfig]:
|
| 77 |
"""Carica la flotta: prova Supabase (tabella `ai_providers`, source of
|
|
@@ -113,6 +122,41 @@ class AIClient:
|
|
| 113 |
))
|
| 114 |
)
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
def _try_load_from_supabase(self) -> list[ProviderConfig]:
|
| 117 |
url = os.getenv("SUPABASE_URL", "")
|
| 118 |
key = os.getenv("SUPABASE_SERVICE_ROLE_KEY", "")
|
|
@@ -142,7 +186,10 @@ class AIClient:
|
|
| 142 |
ProviderConfig(
|
| 143 |
id=row["id"], name=row["name"], api_key=row["api_key"],
|
| 144 |
base_url=row["base_url"], default_model=self._runtime_model_override(row),
|
| 145 |
-
tier=row["tier"], purpose=row["purpose"],
|
|
|
|
|
|
|
|
|
|
| 146 |
)
|
| 147 |
for row in rows
|
| 148 |
]
|
|
@@ -156,6 +203,7 @@ class AIClient:
|
|
| 156 |
è impostata — nessun placeholder, nessun nodo fantasma."""
|
| 157 |
providers = []
|
| 158 |
for i, d in enumerate(_PROVIDER_DEFS):
|
|
|
|
| 159 |
api_key = os.getenv(d["env_key"], "")
|
| 160 |
if not api_key:
|
| 161 |
continue
|
|
@@ -167,23 +215,61 @@ class AIClient:
|
|
| 167 |
default_model=os.getenv(d["model_env"], d["default_model"]),
|
| 168 |
tier=d["tier"],
|
| 169 |
purpose=d["purpose"],
|
| 170 |
-
profile="
|
| 171 |
))
|
| 172 |
if not providers:
|
| 173 |
_logger.error("AIClient: nessuna API key provider configurata (Groq/OpenRouter/Cerebras/SambaNova/Gemini/NVIDIA/HF_TOKEN tutte assenti)")
|
| 174 |
return providers
|
| 175 |
|
| 176 |
def _client_for(self, provider: ProviderConfig) -> OpenAI:
|
| 177 |
-
if provider.
|
| 178 |
-
self._client_cache[provider.
|
| 179 |
-
api_key=provider.api_key,
|
| 180 |
-
base_url=provider.base_url,
|
| 181 |
# I task coding possono richiedere più di 20 s prima del primo
|
| 182 |
# chunk dal fallback gratuito; il budget esterno resta finito.
|
| 183 |
timeout=45,
|
| 184 |
max_retries=0
|
| 185 |
)
|
| 186 |
-
return self._client_cache[provider.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
|
| 188 |
async def _fetch_one(self, provider: ProviderConfig, messages: list, temperature: float, max_tokens: int) -> Tuple[ProviderConfig, str, float]:
|
| 189 |
client = self._client_for(provider)
|
|
@@ -202,9 +288,11 @@ class AIClient:
|
|
| 202 |
# più tempo per produrre una risposta completa dopo uno stream interrotto.
|
| 203 |
timeout=45
|
| 204 |
)
|
|
|
|
| 205 |
return provider, response.choices[0].message.content or "", _time_mod.monotonic() - start
|
| 206 |
except Exception as e:
|
| 207 |
-
|
|
|
|
| 208 |
return provider, f"ERROR: {str(e)}", 0.0
|
| 209 |
|
| 210 |
def _get_round_robin_provider(self, purpose: str) -> Optional[ProviderConfig]:
|
|
@@ -249,7 +337,10 @@ class AIClient:
|
|
| 249 |
if not pool:
|
| 250 |
raise ProviderUnavailableError([])
|
| 251 |
|
| 252 |
-
# 3.
|
|
|
|
|
|
|
|
|
|
| 253 |
tasks = [self._fetch_one(p, messages, temperature, max_tokens) for p in pool]
|
| 254 |
results = await asyncio.gather(*tasks)
|
| 255 |
|
|
@@ -302,7 +393,8 @@ class AIClient:
|
|
| 302 |
except Exception as exc:
|
| 303 |
_logger.debug("Streaming fleet expansion skipped: %s", type(exc).__name__)
|
| 304 |
|
| 305 |
-
# Nello streaming proviamo
|
|
|
|
| 306 |
for provider in providers:
|
| 307 |
client = self._client_for(provider)
|
| 308 |
try:
|
|
@@ -320,9 +412,11 @@ class AIClient:
|
|
| 320 |
if chunk is None: break
|
| 321 |
if chunk.choices and chunk.choices[0].delta.content:
|
| 322 |
yield chunk.choices[0].delta.content
|
|
|
|
| 323 |
return
|
| 324 |
except Exception as e:
|
| 325 |
-
|
|
|
|
| 326 |
continue
|
| 327 |
|
| 328 |
raise ProviderUnavailableError([provider.name for provider in providers])
|
|
|
|
| 14 |
from __future__ import annotations
|
| 15 |
|
| 16 |
import asyncio
|
| 17 |
+
import json
|
| 18 |
import os
|
| 19 |
import time as _time_mod
|
| 20 |
from dataclasses import dataclass
|
|
|
|
| 50 |
purpose: str = "reasoning"
|
| 51 |
profile: str = "general"
|
| 52 |
|
| 53 |
+
@property
|
| 54 |
+
def identity(self) -> tuple[str, str, str]:
|
| 55 |
+
"""Stable identity: different profiles must never share a client cache entry."""
|
| 56 |
+
return (self.name, self.profile, self.base_url)
|
| 57 |
+
|
| 58 |
# Definizione statica dei provider LLM realmente attivi nel progetto.
|
| 59 |
# base_url punta sempre all'endpoint OpenAI-compatible ufficiale del provider
|
| 60 |
# (nessun proxy CF Worker qui: questo client gira lato backend Python, non browser).
|
|
|
|
| 75 |
class AIClient:
|
| 76 |
def __init__(self) -> None:
|
| 77 |
self.providers = self._load_providers()
|
| 78 |
+
self._client_cache: dict[tuple[str, str, str], OpenAI] = {}
|
| 79 |
+
# Round-robin e circuit breaker sono indicizzati per purpose e profilo.
|
| 80 |
self._rr_indices: dict[str, int] = {}
|
| 81 |
+
self._breaker: dict[tuple[str, str, str], dict[str, float | int]] = {}
|
| 82 |
+
self._breaker_threshold = 2
|
| 83 |
+
self._breaker_cooldown_s = 60.0
|
| 84 |
|
| 85 |
def _load_providers(self) -> list[ProviderConfig]:
|
| 86 |
"""Carica la flotta: prova Supabase (tabella `ai_providers`, source of
|
|
|
|
| 122 |
))
|
| 123 |
)
|
| 124 |
|
| 125 |
+
@staticmethod
|
| 126 |
+
def _profile_rows_from_env(definition: dict) -> list[ProviderConfig]:
|
| 127 |
+
"""Load optional per-provider profiles without logging secret values.
|
| 128 |
+
|
| 129 |
+
Format: ``<PROVIDER>_PROFILES_JSON=[{"profile":"p1","api_key":"...", "model":"..."}]``.
|
| 130 |
+
The legacy single-key variable remains supported and is loaded after profiles.
|
| 131 |
+
"""
|
| 132 |
+
env_name = f"{definition['name'].upper()}_PROFILES_JSON"
|
| 133 |
+
raw = os.getenv(env_name, "").strip()
|
| 134 |
+
if not raw:
|
| 135 |
+
return []
|
| 136 |
+
try:
|
| 137 |
+
rows = json.loads(raw)
|
| 138 |
+
except json.JSONDecodeError:
|
| 139 |
+
_logger.warning("AIClient: %s non valido, profili ignorati", env_name)
|
| 140 |
+
return []
|
| 141 |
+
if not isinstance(rows, list):
|
| 142 |
+
_logger.warning("AIClient: %s deve essere un array JSON", env_name)
|
| 143 |
+
return []
|
| 144 |
+
result: list[ProviderConfig] = []
|
| 145 |
+
for index, row in enumerate(rows):
|
| 146 |
+
if not isinstance(row, dict) or not row.get("api_key"):
|
| 147 |
+
continue
|
| 148 |
+
result.append(ProviderConfig(
|
| 149 |
+
id=-(index + 1),
|
| 150 |
+
name=definition["name"],
|
| 151 |
+
api_key=str(row["api_key"]),
|
| 152 |
+
base_url=str(row.get("base_url") or definition["base_url"]),
|
| 153 |
+
default_model=str(row.get("model") or os.getenv(definition["model_env"], definition["default_model"])),
|
| 154 |
+
tier=definition["tier"],
|
| 155 |
+
purpose=str(row.get("purpose") or definition["purpose"]),
|
| 156 |
+
profile=str(row.get("profile") or f"profile-{index + 1}"),
|
| 157 |
+
))
|
| 158 |
+
return result
|
| 159 |
+
|
| 160 |
def _try_load_from_supabase(self) -> list[ProviderConfig]:
|
| 161 |
url = os.getenv("SUPABASE_URL", "")
|
| 162 |
key = os.getenv("SUPABASE_SERVICE_ROLE_KEY", "")
|
|
|
|
| 186 |
ProviderConfig(
|
| 187 |
id=row["id"], name=row["name"], api_key=row["api_key"],
|
| 188 |
base_url=row["base_url"], default_model=self._runtime_model_override(row),
|
| 189 |
+
tier=row["tier"], purpose=row["purpose"],
|
| 190 |
+
# Legacy schema has no profile column: the row id is still a
|
| 191 |
+
# stable profile identity and prevents client-cache collisions.
|
| 192 |
+
profile=f"db-{row['id']}",
|
| 193 |
)
|
| 194 |
for row in rows
|
| 195 |
]
|
|
|
|
| 203 |
è impostata — nessun placeholder, nessun nodo fantasma."""
|
| 204 |
providers = []
|
| 205 |
for i, d in enumerate(_PROVIDER_DEFS):
|
| 206 |
+
providers.extend(self._profile_rows_from_env(d))
|
| 207 |
api_key = os.getenv(d["env_key"], "")
|
| 208 |
if not api_key:
|
| 209 |
continue
|
|
|
|
| 215 |
default_model=os.getenv(d["model_env"], d["default_model"]),
|
| 216 |
tier=d["tier"],
|
| 217 |
purpose=d["purpose"],
|
| 218 |
+
profile="legacy",
|
| 219 |
))
|
| 220 |
if not providers:
|
| 221 |
_logger.error("AIClient: nessuna API key provider configurata (Groq/OpenRouter/Cerebras/SambaNova/Gemini/NVIDIA/HF_TOKEN tutte assenti)")
|
| 222 |
return providers
|
| 223 |
|
| 224 |
def _client_for(self, provider: ProviderConfig) -> OpenAI:
|
| 225 |
+
if provider.identity not in self._client_cache:
|
| 226 |
+
self._client_cache[provider.identity] = OpenAI(
|
| 227 |
+
api_key=provider.api_key,
|
| 228 |
+
base_url=provider.base_url,
|
| 229 |
# I task coding possono richiedere più di 20 s prima del primo
|
| 230 |
# chunk dal fallback gratuito; il budget esterno resta finito.
|
| 231 |
timeout=45,
|
| 232 |
max_retries=0
|
| 233 |
)
|
| 234 |
+
return self._client_cache[provider.identity]
|
| 235 |
+
|
| 236 |
+
def _is_available(self, provider: ProviderConfig) -> bool:
|
| 237 |
+
state = self._breaker.get(provider.identity)
|
| 238 |
+
return not state or float(state.get("open_until", 0.0)) <= _time_mod.monotonic()
|
| 239 |
+
|
| 240 |
+
def _record_success(self, provider: ProviderConfig) -> None:
|
| 241 |
+
self._breaker.pop(provider.identity, None)
|
| 242 |
+
|
| 243 |
+
def _record_failure(self, provider: ProviderConfig, exc: Exception) -> None:
|
| 244 |
+
message = str(exc).lower()
|
| 245 |
+
if not any(token in message for token in ("401", "403", "429", "500", "502", "503", "504", "rate limit", "quota")):
|
| 246 |
+
return
|
| 247 |
+
state = self._breaker.setdefault(provider.identity, {"failures": 0, "open_until": 0.0})
|
| 248 |
+
failures = int(state.get("failures", 0)) + 1
|
| 249 |
+
severe = any(token in message for token in ("401", "403"))
|
| 250 |
+
threshold = 1 if severe else self._breaker_threshold
|
| 251 |
+
if failures >= threshold:
|
| 252 |
+
cooldown = 900.0 if severe else (120.0 if any(token in message for token in ("429", "rate limit", "quota")) else self._breaker_cooldown_s)
|
| 253 |
+
state["open_until"] = _time_mod.monotonic() + cooldown
|
| 254 |
+
state["failures"] = failures
|
| 255 |
+
|
| 256 |
+
def _execution_pool(self, providers: list[ProviderConfig], purpose: str) -> list[ProviderConfig]:
|
| 257 |
+
"""Return one rotated, healthy profile per provider endpoint group."""
|
| 258 |
+
groups: dict[tuple[str, str], list[ProviderConfig]] = {}
|
| 259 |
+
for provider in providers:
|
| 260 |
+
if not self._is_available(provider):
|
| 261 |
+
continue
|
| 262 |
+
groups.setdefault((provider.name, provider.base_url), []).append(provider)
|
| 263 |
+
selected: list[ProviderConfig] = []
|
| 264 |
+
for group_key, profiles in groups.items():
|
| 265 |
+
index_key = f"{purpose}:{group_key[0]}:{group_key[1]}"
|
| 266 |
+
start = self._rr_indices.get(index_key, 0)
|
| 267 |
+
selected.append(profiles[start % len(profiles)])
|
| 268 |
+
self._rr_indices[index_key] = start + 1
|
| 269 |
+
if selected:
|
| 270 |
+
return selected
|
| 271 |
+
# All profiles are quarantined: probe the earliest-to-recover profile only.
|
| 272 |
+
return [min(providers, key=lambda p: self._breaker.get(p.identity, {}).get("open_until", 0.0))] if providers else []
|
| 273 |
|
| 274 |
async def _fetch_one(self, provider: ProviderConfig, messages: list, temperature: float, max_tokens: int) -> Tuple[ProviderConfig, str, float]:
|
| 275 |
client = self._client_for(provider)
|
|
|
|
| 288 |
# più tempo per produrre una risposta completa dopo uno stream interrotto.
|
| 289 |
timeout=45
|
| 290 |
)
|
| 291 |
+
self._record_success(provider)
|
| 292 |
return provider, response.choices[0].message.content or "", _time_mod.monotonic() - start
|
| 293 |
except Exception as e:
|
| 294 |
+
self._record_failure(provider, e)
|
| 295 |
+
_logger.warning(f"Provider {provider.name}/{provider.profile} fallito: {e}")
|
| 296 |
return provider, f"ERROR: {str(e)}", 0.0
|
| 297 |
|
| 298 |
def _get_round_robin_provider(self, purpose: str) -> Optional[ProviderConfig]:
|
|
|
|
| 337 |
if not pool:
|
| 338 |
raise ProviderUnavailableError([])
|
| 339 |
|
| 340 |
+
# 3. Un solo profilo per endpoint e richiesta: round-robin evita che
|
| 341 |
+
# profili OpenRouter condividano quota e client, mentre provider diversi
|
| 342 |
+
# restano disponibili come ensemble/fallback.
|
| 343 |
+
pool = self._execution_pool(pool, primary_purpose)
|
| 344 |
tasks = [self._fetch_one(p, messages, temperature, max_tokens) for p in pool]
|
| 345 |
results = await asyncio.gather(*tasks)
|
| 346 |
|
|
|
|
| 393 |
except Exception as exc:
|
| 394 |
_logger.debug("Streaming fleet expansion skipped: %s", type(exc).__name__)
|
| 395 |
|
| 396 |
+
# Nello streaming proviamo un profilo ruotato per endpoint, poi i fallback runtime.
|
| 397 |
+
providers = self._execution_pool(providers, "stream")
|
| 398 |
for provider in providers:
|
| 399 |
client = self._client_for(provider)
|
| 400 |
try:
|
|
|
|
| 412 |
if chunk is None: break
|
| 413 |
if chunk.choices and chunk.choices[0].delta.content:
|
| 414 |
yield chunk.choices[0].delta.content
|
| 415 |
+
self._record_success(provider)
|
| 416 |
return
|
| 417 |
except Exception as e:
|
| 418 |
+
self._record_failure(provider, e)
|
| 419 |
+
_logger.warning(f"Streaming fallito su {provider.name}/{provider.profile}: {e}")
|
| 420 |
continue
|
| 421 |
|
| 422 |
raise ProviderUnavailableError([provider.name for provider in providers])
|
models/role_router.py
CHANGED
|
@@ -83,11 +83,37 @@ class RoleRouter:
|
|
| 83 |
|
| 84 |
# ── Role-specific builders ─────────────────────────────────────────────────
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
@staticmethod
|
| 87 |
def _fast_client() -> Any:
|
| 88 |
"""Groq GPT-OSS 20B per query brevi e a bassa latenza.
|
| 89 |
Usato per: greetings, calcoli semplici, identity, domande 1-liner."""
|
| 90 |
from models.ai_client import AIClient, ProviderConfig
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
groq_key = os.getenv("GROQ_API_KEY")
|
| 92 |
if not groq_key:
|
| 93 |
return RoleRouter._tester_client()
|
|
@@ -110,6 +136,10 @@ class RoleRouter:
|
|
| 110 |
"""NVIDIA NIM come primario per architettura.
|
| 111 |
Fallback 1: Groq GPT-OSS 120B. Fallback 2: OpenRouter GPT-OSS 20B gratuito."""
|
| 112 |
from models.ai_client import AIClient, ProviderConfig
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
nvidia_key = os.getenv("NVIDIA_API_KEY")
|
| 114 |
if nvidia_key:
|
| 115 |
client = AIClient()
|
|
@@ -141,21 +171,22 @@ class RoleRouter:
|
|
| 141 |
client.default_model = architect.default_model
|
| 142 |
client.client = client._client_for(architect)
|
| 143 |
return client
|
| 144 |
-
# Fallback
|
| 145 |
-
|
| 146 |
-
if openrouter_key:
|
| 147 |
client = AIClient()
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
|
|
|
|
|
|
| 159 |
return client
|
| 160 |
return AIClient()
|
| 161 |
|
|
@@ -164,6 +195,10 @@ class RoleRouter:
|
|
| 164 |
"""Groq GPT-OSS 120B per coding e debug.
|
| 165 |
Fallback: provider ordinari del router se GROQ_API_KEY manca."""
|
| 166 |
from models.ai_client import AIClient, ProviderConfig
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
groq_key = os.getenv("GROQ_API_KEY")
|
| 168 |
nvidia_key = os.getenv("NVIDIA_API_KEY")
|
| 169 |
model = os.getenv("CODER_MODEL", "openai/gpt-oss-120b")
|
|
@@ -198,21 +233,22 @@ class RoleRouter:
|
|
| 198 |
client.default_model = coder.default_model
|
| 199 |
client.client = client._client_for(coder)
|
| 200 |
return client
|
| 201 |
-
|
| 202 |
-
if
|
| 203 |
client = AIClient()
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
|
|
|
| 216 |
return client
|
| 217 |
return AIClient()
|
| 218 |
|
|
@@ -225,9 +261,12 @@ class RoleRouter:
|
|
| 225 |
Groq è sano. L'ordine conserva tutti i provider ordinari dopo Groq.
|
| 226 |
"""
|
| 227 |
from models.ai_client import AIClient, ProviderConfig
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
groq_key = os.getenv("GROQ_API_KEY")
|
| 229 |
if groq_key:
|
| 230 |
-
client = AIClient()
|
| 231 |
researcher = ProviderConfig(
|
| 232 |
name="groq-researcher",
|
| 233 |
api_key=groq_key,
|
|
@@ -256,9 +295,12 @@ class RoleRouter:
|
|
| 256 |
risposte deterministiche a scelta multipla. Cerebras resta un fallback
|
| 257 |
compatibile quando Groq non è configurato."""
|
| 258 |
from models.ai_client import AIClient, ProviderConfig
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
groq_key = os.getenv("GROQ_API_KEY")
|
| 260 |
if groq_key:
|
| 261 |
-
client = AIClient()
|
| 262 |
reasoner = ProviderConfig(
|
| 263 |
name="groq-reasoner",
|
| 264 |
api_key=groq_key,
|
|
@@ -301,6 +343,10 @@ class RoleRouter:
|
|
| 301 |
gemma-4-31B-it: 100% ma 2132ms. Meta-Llama: rate-limited. gpt-oss-120b: ERR.
|
| 302 |
Fallback: _architect_client (Groq) se SAMBANOVA_API_KEY mancante."""
|
| 303 |
from models.ai_client import AIClient, ProviderConfig
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
sn_key = os.getenv("SAMBANOVA_API_KEY")
|
| 305 |
if not sn_key:
|
| 306 |
return RoleRouter._architect_client()
|
|
@@ -323,6 +369,10 @@ class RoleRouter:
|
|
| 323 |
"""NVIDIA NIM nemotron-3-ultra-550b-a55b — 550B params, 1M ctx, API OpenAI-compat.
|
| 324 |
Fallback: _architect_client (Groq) se NVIDIA_API_KEY mancante."""
|
| 325 |
from models.ai_client import AIClient, ProviderConfig
|
|
|
|
|
|
|
|
|
|
|
|
|
| 326 |
nvidia_key = os.getenv("NVIDIA_API_KEY")
|
| 327 |
if not nvidia_key:
|
| 328 |
return RoleRouter._architect_client()
|
|
@@ -344,9 +394,13 @@ class RoleRouter:
|
|
| 344 |
def _tester_client() -> Any:
|
| 345 |
"""Groq GPT-OSS 20B per test rapidi e debug hints."""
|
| 346 |
from models.ai_client import AIClient, ProviderConfig
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
groq_key = os.getenv("GROQ_API_KEY")
|
| 348 |
if not groq_key:
|
| 349 |
-
return
|
| 350 |
client = AIClient()
|
| 351 |
tester = ProviderConfig(
|
| 352 |
name="groq-tester",
|
|
|
|
| 83 |
|
| 84 |
# ── Role-specific builders ─────────────────────────────────────────────────
|
| 85 |
|
| 86 |
+
@staticmethod
|
| 87 |
+
def _prioritize_profile_pool(client: Any, provider_name: str) -> Any:
|
| 88 |
+
"""Promote all configured profiles for one provider without collapsing them."""
|
| 89 |
+
profiles = [p for p in client.providers if p.name == provider_name]
|
| 90 |
+
if not profiles:
|
| 91 |
+
return None
|
| 92 |
+
client.providers = profiles + [p for p in client.providers if p.name != provider_name]
|
| 93 |
+
client.provider_name = profiles[0].name
|
| 94 |
+
client.default_model = profiles[0].default_model
|
| 95 |
+
client.client = client._client_for(profiles[0])
|
| 96 |
+
return client
|
| 97 |
+
|
| 98 |
+
@staticmethod
|
| 99 |
+
def _profiled_client(client: Any, provider_names: tuple[str, ...]) -> Any:
|
| 100 |
+
for provider_name in provider_names:
|
| 101 |
+
env_name = f"{provider_name.upper()}_PROFILES_JSON"
|
| 102 |
+
if os.getenv(env_name):
|
| 103 |
+
profiled = RoleRouter._prioritize_profile_pool(client, provider_name)
|
| 104 |
+
if profiled:
|
| 105 |
+
return profiled
|
| 106 |
+
return None
|
| 107 |
+
|
| 108 |
@staticmethod
|
| 109 |
def _fast_client() -> Any:
|
| 110 |
"""Groq GPT-OSS 20B per query brevi e a bassa latenza.
|
| 111 |
Usato per: greetings, calcoli semplici, identity, domande 1-liner."""
|
| 112 |
from models.ai_client import AIClient, ProviderConfig
|
| 113 |
+
client = AIClient()
|
| 114 |
+
profiled = RoleRouter._profiled_client(client, ("groq",))
|
| 115 |
+
if profiled:
|
| 116 |
+
return profiled
|
| 117 |
groq_key = os.getenv("GROQ_API_KEY")
|
| 118 |
if not groq_key:
|
| 119 |
return RoleRouter._tester_client()
|
|
|
|
| 136 |
"""NVIDIA NIM come primario per architettura.
|
| 137 |
Fallback 1: Groq GPT-OSS 120B. Fallback 2: OpenRouter GPT-OSS 20B gratuito."""
|
| 138 |
from models.ai_client import AIClient, ProviderConfig
|
| 139 |
+
client = AIClient()
|
| 140 |
+
profiled = RoleRouter._profiled_client(client, ("nvidia", "groq", "openrouter"))
|
| 141 |
+
if profiled:
|
| 142 |
+
return profiled
|
| 143 |
nvidia_key = os.getenv("NVIDIA_API_KEY")
|
| 144 |
if nvidia_key:
|
| 145 |
client = AIClient()
|
|
|
|
| 171 |
client.default_model = architect.default_model
|
| 172 |
client.client = client._client_for(architect)
|
| 173 |
return client
|
| 174 |
+
# Fallback OpenRouter: usa il pool multi-profilo, se configurato.
|
| 175 |
+
if os.getenv("OPENROUTER_API_KEY") or os.getenv("OPENROUTER_PROFILES_JSON"):
|
|
|
|
| 176 |
client = AIClient()
|
| 177 |
+
profiles = [p for p in client.providers if p.name == "openrouter"]
|
| 178 |
+
if not profiles and os.getenv("OPENROUTER_API_KEY"):
|
| 179 |
+
profiles = [ProviderConfig(
|
| 180 |
+
name="openrouter", api_key=os.getenv("OPENROUTER_API_KEY", ""),
|
| 181 |
+
base_url="https://openrouter.ai/api/v1",
|
| 182 |
+
default_model=os.getenv("OPENROUTER_MODEL", "openai/gpt-oss-20b:free"),
|
| 183 |
+
profile="legacy",
|
| 184 |
+
)]
|
| 185 |
+
if profiles:
|
| 186 |
+
client.providers = profiles + [p for p in client.providers if p.name != "openrouter"]
|
| 187 |
+
client.provider_name = profiles[0].name
|
| 188 |
+
client.default_model = profiles[0].default_model
|
| 189 |
+
client.client = client._client_for(profiles[0])
|
| 190 |
return client
|
| 191 |
return AIClient()
|
| 192 |
|
|
|
|
| 195 |
"""Groq GPT-OSS 120B per coding e debug.
|
| 196 |
Fallback: provider ordinari del router se GROQ_API_KEY manca."""
|
| 197 |
from models.ai_client import AIClient, ProviderConfig
|
| 198 |
+
client = AIClient()
|
| 199 |
+
profiled = RoleRouter._profiled_client(client, ("groq", "nvidia", "openrouter"))
|
| 200 |
+
if profiled:
|
| 201 |
+
return profiled
|
| 202 |
groq_key = os.getenv("GROQ_API_KEY")
|
| 203 |
nvidia_key = os.getenv("NVIDIA_API_KEY")
|
| 204 |
model = os.getenv("CODER_MODEL", "openai/gpt-oss-120b")
|
|
|
|
| 233 |
client.default_model = coder.default_model
|
| 234 |
client.client = client._client_for(coder)
|
| 235 |
return client
|
| 236 |
+
# Fallback OpenRouter: usa il pool multi-profilo, se configurato.
|
| 237 |
+
if os.getenv("OPENROUTER_API_KEY") or os.getenv("OPENROUTER_PROFILES_JSON"):
|
| 238 |
client = AIClient()
|
| 239 |
+
profiles = [p for p in client.providers if p.name == "openrouter"]
|
| 240 |
+
if not profiles and os.getenv("OPENROUTER_API_KEY"):
|
| 241 |
+
profiles = [ProviderConfig(
|
| 242 |
+
name="openrouter", api_key=os.getenv("OPENROUTER_API_KEY", ""),
|
| 243 |
+
base_url="https://openrouter.ai/api/v1",
|
| 244 |
+
default_model=os.getenv("OPENROUTER_MODEL", "openai/gpt-oss-20b:free"),
|
| 245 |
+
profile="legacy",
|
| 246 |
+
)]
|
| 247 |
+
if profiles:
|
| 248 |
+
client.providers = profiles + [p for p in client.providers if p.name != "openrouter"]
|
| 249 |
+
client.provider_name = profiles[0].name
|
| 250 |
+
client.default_model = profiles[0].default_model
|
| 251 |
+
client.client = client._client_for(profiles[0])
|
| 252 |
return client
|
| 253 |
return AIClient()
|
| 254 |
|
|
|
|
| 261 |
Groq è sano. L'ordine conserva tutti i provider ordinari dopo Groq.
|
| 262 |
"""
|
| 263 |
from models.ai_client import AIClient, ProviderConfig
|
| 264 |
+
client = AIClient()
|
| 265 |
+
profiled = RoleRouter._profiled_client(client, ("groq", "gemini", "openrouter"))
|
| 266 |
+
if profiled:
|
| 267 |
+
return profiled
|
| 268 |
groq_key = os.getenv("GROQ_API_KEY")
|
| 269 |
if groq_key:
|
|
|
|
| 270 |
researcher = ProviderConfig(
|
| 271 |
name="groq-researcher",
|
| 272 |
api_key=groq_key,
|
|
|
|
| 295 |
risposte deterministiche a scelta multipla. Cerebras resta un fallback
|
| 296 |
compatibile quando Groq non è configurato."""
|
| 297 |
from models.ai_client import AIClient, ProviderConfig
|
| 298 |
+
client = AIClient()
|
| 299 |
+
profiled = RoleRouter._profiled_client(client, ("groq", "cerebras", "gemini", "openrouter"))
|
| 300 |
+
if profiled:
|
| 301 |
+
return profiled
|
| 302 |
groq_key = os.getenv("GROQ_API_KEY")
|
| 303 |
if groq_key:
|
|
|
|
| 304 |
reasoner = ProviderConfig(
|
| 305 |
name="groq-reasoner",
|
| 306 |
api_key=groq_key,
|
|
|
|
| 343 |
gemma-4-31B-it: 100% ma 2132ms. Meta-Llama: rate-limited. gpt-oss-120b: ERR.
|
| 344 |
Fallback: _architect_client (Groq) se SAMBANOVA_API_KEY mancante."""
|
| 345 |
from models.ai_client import AIClient, ProviderConfig
|
| 346 |
+
client = AIClient()
|
| 347 |
+
profiled = RoleRouter._profiled_client(client, ("sambanova",))
|
| 348 |
+
if profiled:
|
| 349 |
+
return profiled
|
| 350 |
sn_key = os.getenv("SAMBANOVA_API_KEY")
|
| 351 |
if not sn_key:
|
| 352 |
return RoleRouter._architect_client()
|
|
|
|
| 369 |
"""NVIDIA NIM nemotron-3-ultra-550b-a55b — 550B params, 1M ctx, API OpenAI-compat.
|
| 370 |
Fallback: _architect_client (Groq) se NVIDIA_API_KEY mancante."""
|
| 371 |
from models.ai_client import AIClient, ProviderConfig
|
| 372 |
+
client = AIClient()
|
| 373 |
+
profiled = RoleRouter._profiled_client(client, ("nvidia",))
|
| 374 |
+
if profiled:
|
| 375 |
+
return profiled
|
| 376 |
nvidia_key = os.getenv("NVIDIA_API_KEY")
|
| 377 |
if not nvidia_key:
|
| 378 |
return RoleRouter._architect_client()
|
|
|
|
| 394 |
def _tester_client() -> Any:
|
| 395 |
"""Groq GPT-OSS 20B per test rapidi e debug hints."""
|
| 396 |
from models.ai_client import AIClient, ProviderConfig
|
| 397 |
+
client = AIClient()
|
| 398 |
+
profiled = RoleRouter._profiled_client(client, ("groq",))
|
| 399 |
+
if profiled:
|
| 400 |
+
return profiled
|
| 401 |
groq_key = os.getenv("GROQ_API_KEY")
|
| 402 |
if not groq_key:
|
| 403 |
+
return client
|
| 404 |
client = AIClient()
|
| 405 |
tester = ProviderConfig(
|
| 406 |
name="groq-tester",
|
tests/test_provider_profile_pool.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
|
| 8 |
+
|
| 9 |
+
class ProviderProfilePoolTests(unittest.TestCase):
|
| 10 |
+
def _profiles(self):
|
| 11 |
+
return [
|
| 12 |
+
ProviderConfig(name="openrouter", api_key="test-key-a", base_url="https://openrouter.ai/api/v1", profile="a", purpose="coding"),
|
| 13 |
+
ProviderConfig(name="openrouter", api_key="test-key-b", base_url="https://openrouter.ai/api/v1", profile="b", purpose="coding"),
|
| 14 |
+
ProviderConfig(name="openrouter", api_key="test-key-c", base_url="https://openrouter.ai/api/v1", profile="c", purpose="coding"),
|
| 15 |
+
]
|
| 16 |
+
|
| 17 |
+
def test_profile_json_loads_alongside_legacy_key(self):
|
| 18 |
+
raw = json.dumps([
|
| 19 |
+
{"profile": "primary", "api_key": "profile-key-1"},
|
| 20 |
+
{"profile": "backup", "api_key": "profile-key-2", "model": "openai/gpt-oss-20b:free"},
|
| 21 |
+
])
|
| 22 |
+
with patch.dict(os.environ, {"OPENROUTER_PROFILES_JSON": raw, "OPENROUTER_API_KEY": "legacy-key"}, clear=True):
|
| 23 |
+
client = AIClient()
|
| 24 |
+
profiles = [p for p in client.providers if p.name == "openrouter"]
|
| 25 |
+
self.assertEqual([p.profile for p in profiles], ["primary", "backup", "legacy"])
|
| 26 |
+
self.assertEqual([p.api_key for p in profiles], ["profile-key-1", "profile-key-2", "legacy-key"])
|
| 27 |
+
|
| 28 |
+
def test_profile_json_is_supported_for_every_provider(self):
|
| 29 |
+
env = {
|
| 30 |
+
f"{definition['name'].upper()}_PROFILES_JSON": json.dumps([
|
| 31 |
+
{"profile": "primary", "api_key": f"{definition['name']}-key"},
|
| 32 |
+
{"profile": "backup", "api_key": f"{definition['name']}-backup"},
|
| 33 |
+
])
|
| 34 |
+
for definition in _PROVIDER_DEFS
|
| 35 |
+
}
|
| 36 |
+
with patch.dict(os.environ, env, clear=True):
|
| 37 |
+
client = AIClient()
|
| 38 |
+
for definition in _PROVIDER_DEFS:
|
| 39 |
+
profiles = [p for p in client.providers if p.name == definition["name"]]
|
| 40 |
+
self.assertEqual([p.profile for p in profiles], ["primary", "backup"])
|
| 41 |
+
|
| 42 |
+
def test_profiles_have_distinct_client_cache_entries(self):
|
| 43 |
+
client = AIClient()
|
| 44 |
+
first, second = self._profiles()[:2]
|
| 45 |
+
first_client = client._client_for(first)
|
| 46 |
+
second_client = client._client_for(second)
|
| 47 |
+
self.assertIsNot(first_client, second_client)
|
| 48 |
+
self.assertEqual(len(client._client_cache), 2)
|
| 49 |
+
|
| 50 |
+
def test_round_robin_rotates_profiles_and_skips_open_circuit(self):
|
| 51 |
+
client = AIClient()
|
| 52 |
+
profiles = self._profiles()
|
| 53 |
+
self.assertEqual(client._execution_pool(profiles, "coding")[0].profile, "a")
|
| 54 |
+
self.assertEqual(client._execution_pool(profiles, "coding")[0].profile, "b")
|
| 55 |
+
client._record_failure(profiles[1], RuntimeError("HTTP 429 rate limit"))
|
| 56 |
+
client._record_failure(profiles[1], RuntimeError("HTTP 429 rate limit"))
|
| 57 |
+
self.assertFalse(client._is_available(profiles[1]))
|
| 58 |
+
selected = client._execution_pool(profiles, "coding")
|
| 59 |
+
self.assertNotEqual(selected[0].profile, "b")
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
if __name__ == "__main__":
|
| 63 |
+
unittest.main()
|