Baida07 commited on
Commit
6e476cf
·
1 Parent(s): ef0a3b4

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 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
- # S-DUAL-10: Indice per round-robin tra provider dello stesso purpose
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"], profile="general",
 
 
 
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="general",
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.name not in self._client_cache:
178
- self._client_cache[provider.name] = OpenAI(
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.name]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- _logger.warning(f"Provider {provider.name} fallito: {e}")
 
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. Esecuzione parallela (Ensemble Intelligence)
 
 
 
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 il primario di ruolo, poi i fallback runtime.
 
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
- _logger.warning(f"Streaming fallito su {provider.name}: {e}")
 
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: OpenRouter GPT-OSS 20B gratuito, con ID verificato nel catalogo.
145
- openrouter_key = os.getenv("OPENROUTER_API_KEY")
146
- if openrouter_key:
147
  client = AIClient()
148
- fallback = ProviderConfig(
149
- name="openrouter-architect",
150
- api_key=openrouter_key,
151
- base_url="https://openrouter.ai/api/v1",
152
- default_model=os.getenv("OPENROUTER_MODEL", "openai/gpt-oss-20b:free"),
153
- )
154
- rest = [p for p in client.providers if not p.name.startswith("openrouter")]
155
- client.providers = [fallback, *rest]
156
- client.provider_name = fallback.name
157
- client.default_model = fallback.default_model
158
- client.client = client._client_for(fallback)
 
 
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
- openrouter_key = os.getenv("OPENROUTER_API_KEY")
202
- if openrouter_key:
203
  client = AIClient()
204
- fallback = ProviderConfig(
205
- name="openrouter-coder",
206
- api_key=openrouter_key,
207
- base_url="https://openrouter.ai/api/v1",
208
- default_model=os.getenv("OPENROUTER_MODEL", "openai/gpt-oss-20b:free"),
209
- purpose="coding",
210
- )
211
- rest = [p for p in client.providers if not p.name.startswith("openrouter")]
212
- client.providers = [fallback, *rest]
213
- client.provider_name = fallback.name
214
- client.default_model = fallback.default_model
215
- client.client = client._client_for(fallback)
 
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 AIClient()
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()