Baida07 commited on
Commit
201bed4
·
1 Parent(s): 2b29d05

sync: 150 file da Baida98/AI@eec8e22f (2026-08-14 17:10 UTC) [deploy-all] (#29)

Browse files

- sync: 150 file da Baida98/AI@eec8e22f (2026-08-14 17:10 UTC) [deploy-all] (de6fcf343ff73e4a471bab6e3608db6f77011c70)

agents/planner.py CHANGED
@@ -210,7 +210,7 @@ class Planner:
210
 
211
  def _get_fast_llm(self) -> AIClient:
212
  """Gap-5: Cerebras gpt-oss-120b (2000+ tok/s) per quick-start draft.
213
- Fallback: Groq llama-3.1-8b-instant se CEREBRAS_API_KEY assente."""
214
  try:
215
  from models.role_router import RoleRouter, Role
216
  return RoleRouter.get_client(Role.REASONER) # Cerebras 120B
 
210
 
211
  def _get_fast_llm(self) -> AIClient:
212
  """Gap-5: Cerebras gpt-oss-120b (2000+ tok/s) per quick-start draft.
213
+ Fallback: Groq openai/gpt-oss-20b se CEREBRAS_API_KEY assente."""
214
  try:
215
  from models.role_router import RoleRouter, Role
216
  return RoleRouter.get_client(Role.REASONER) # Cerebras 120B
agents/unified_loop_llm.py CHANGED
@@ -65,7 +65,7 @@ class LLMSelectionMixin:
65
  return self._coder_llm
66
 
67
  def _get_fast_llm(self) -> Any:
68
- """S-FAST: return Role.FAST client (Groq llama-3.1-8b-instant) per query semplici.
69
  Caricato lazy e cachato in self._fast_llm — zero overhead dopo il primo accesso.
70
  Fallback silenzioso su self.llm se GROQ_API_KEY mancante o RoleRouter non disponibile."""
71
  if self._fast_llm is None:
@@ -284,11 +284,18 @@ class LLMSelectionMixin:
284
  _FORMAT_DIRECTIVE_CODE = (
285
  "FORMATO RISPOSTA OBBLIGATORIO — CODICE:\n"
286
  "• Usa SEMPRE blocchi markdown con linguaggio specificato (```python, ```typescript, ecc.)\n"
287
- "• Struttura: breve spiegazione → blocco codice completo → come usarlo\n"
288
- "• Ogni blocco deve essere autonomo ed eseguibile senza modifiche\n"
289
- "• Aggiungi commenti inline per la logica non ovvia\n"
290
- "• Se multi-file: mostra ogni file in un blocco separato con il nome come titolo\n"
291
- "• Formato titolo file OBBLIGATORIO: ### src/nomefile.tsx (H3 - risparmia spazio verticale su mobile)"
 
 
 
 
 
 
 
292
  )
293
  _FORMAT_DIRECTIVE_MARKDOWN = (
294
  "FORMATO RISPOSTA OBBLIGATORIO — STRUTTURATO:\n"
 
65
  return self._coder_llm
66
 
67
  def _get_fast_llm(self) -> Any:
68
+ """S-FAST: return Role.FAST client (Groq openai/gpt-oss-20b) per query semplici.
69
  Caricato lazy e cachato in self._fast_llm — zero overhead dopo il primo accesso.
70
  Fallback silenzioso su self.llm se GROQ_API_KEY mancante o RoleRouter non disponibile."""
71
  if self._fast_llm is None:
 
284
  _FORMAT_DIRECTIVE_CODE = (
285
  "FORMATO RISPOSTA OBBLIGATORIO — CODICE:\n"
286
  "• Usa SEMPRE blocchi markdown con linguaggio specificato (```python, ```typescript, ecc.)\n"
287
+ "• Per una richiesta di singolo snippet, emetti ESATTAMENTE un blocco nel linguaggio richiesto; "
288
+ "non sostituirlo con pseudocodice, analisi o un blocco generico.\n"
289
+ "• Il blocco deve contenere la soluzione completa, autonoma ed eseguibile senza modifiche; "
290
+ "mantieni gli export e la firma richiesti.\n"
291
+ "• Prima di rispondere applica il CONTROLLO FINALE: codice compilabile, nessun placeholder/TODO, "
292
+ "nessun simbolo non definito, tipi espliciti.\n"
293
+ "• Per codice async con handler indipendenti: includi `async`, `await` e `try/catch` oppure "
294
+ "`Promise.allSettled` per isolare ogni errore.\n"
295
+ "• Per correzioni React useEffect: preserva la struttura, usa AbortController o una guardia di annullamento "
296
+ "e restituisci sempre cleanup (`return () => ...`).\n"
297
+ "• Aggiungi commenti inline solo per la logica non ovvia. Se multi-file: mostra ogni file in un blocco separato "
298
+ "con il nome come titolo; formato titolo: ### src/nomefile.tsx."
299
  )
300
  _FORMAT_DIRECTIVE_MARKDOWN = (
301
  "FORMATO RISPOSTA OBBLIGATORIO — STRUTTURATO:\n"
agents/unified_loop_prompts.py CHANGED
@@ -49,8 +49,8 @@ class PromptBuilderMixin:
49
  "8c. OBBLIGO TypeScript: ogni snippet di codice TypeScript DEVE essere in blocchi "
50
  "```typescript```...```typescript. Mai inline, mai in blocchi generici. "
51
  "Il codice deve compilare: nessun placeholder, nessun TODO, tipi espliciti. In caso di REFACTORING: sostituisci SEMPRE nomi di variabili a lettera singola (p, m, v) con nomi semantici e descrittivi, e usa interfacce o tipi per ogni oggetto complesso.\n"
52
- "8d. REASONING: Per problemi complessi, scomponi il problema in sotto-task logici. Verifica la coerenza dei risultati intermedi prima di procedere al calcolo finale.
53
- 9. Per decisioni architetturali: dai 3 opzioni con pro/contro e raccomandazione\n"
54
  "10. NON inventare mai informazioni su te stesso: token usati, context window, "
55
  "versione, architettura, parametri interni. Se non lo sai con certezza, "
56
  "di esplicitamente 'non ho accesso a questa informazione'.\n"
@@ -306,7 +306,7 @@ class PromptBuilderMixin:
306
 
307
  # ── S200: Context-aware rule injection ──────────────────────────────────────
308
  # Seleziona solo le regole rilevanti per il task corrente.
309
- # Con llama-3.1-8b-instant (8K context), mettere tutto nel system prompt
310
  # causa troncamento silenzioso — le regole non vengono mai lette.
311
  # Soluzione: iniettare 2-4 regole contestuali ALLA FINE del user message
312
  # (posizione con massima attenzione del modello = "recency bias").
 
49
  "8c. OBBLIGO TypeScript: ogni snippet di codice TypeScript DEVE essere in blocchi "
50
  "```typescript```...```typescript. Mai inline, mai in blocchi generici. "
51
  "Il codice deve compilare: nessun placeholder, nessun TODO, tipi espliciti. In caso di REFACTORING: sostituisci SEMPRE nomi di variabili a lettera singola (p, m, v) con nomi semantici e descrittivi, e usa interfacce o tipi per ogni oggetto complesso.\n"
52
+ "8d. REASONING: Per problemi complessi, scomponi il problema in sotto-task logici. Verifica la coerenza dei risultati intermedi prima di procedere al calcolo finale.\n"
53
+ "9. Per decisioni architetturali: dai 3 opzioni con pro/contro e raccomandazione\n"
54
  "10. NON inventare mai informazioni su te stesso: token usati, context window, "
55
  "versione, architettura, parametri interni. Se non lo sai con certezza, "
56
  "di esplicitamente 'non ho accesso a questa informazione'.\n"
 
306
 
307
  # ── S200: Context-aware rule injection ──────────────────────────────────────
308
  # Seleziona solo le regole rilevanti per il task corrente.
309
+ # Con openai/gpt-oss-20b (8K context), mettere tutto nel system prompt
310
  # causa troncamento silenzioso — le regole non vengono mai lette.
311
  # Soluzione: iniettare 2-4 regole contestuali ALLA FINE del user message
312
  # (posizione con massima attenzione del modello = "recency bias").
api/auth_guard.py CHANGED
@@ -228,6 +228,38 @@ async def _resolve_role(
228
  return AuthRole.USER
229
 
230
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
  def require_role(min_role: AuthRole):
232
  """
233
  FastAPI Depends factory per autorizzazione granulare.
 
228
  return AuthRole.USER
229
 
230
 
231
+ async def require_private_state_machine(
232
+ request: 'Request',
233
+ x_internal_token: Optional[str] = Header(None, alias="X-Internal-Token"),
234
+ ) -> AuthRole:
235
+ """Autorizza esclusivamente il proxy Pages dello stato privato.
236
+
237
+ Usa un token dedicato per non ruotare o esporre ``INTERNAL_TOKEN``, da cui
238
+ dipendono le integrazioni legacy del master B. Il token non conferisce un
239
+ ruolo più ampio del canale MACHINE e resta soggetto allo stesso rate limit.
240
+ """
241
+ import secrets as _sec_comp
242
+ private_token = _get_token("PRIVATE_STATE_INTERNAL_TOKEN")
243
+ if not private_token:
244
+ raise HTTPException(status_code=503, detail="Canale stato privato non configurato")
245
+ if not x_internal_token or not _sec_comp.compare_digest(x_internal_token, private_token):
246
+ raise HTTPException(status_code=403, detail="Permessi insufficienti per lo stato privato")
247
+
248
+ client_ip = (
249
+ request.headers.get('X-Forwarded-For', '').split(',')[0].strip()
250
+ or request.headers.get('X-Real-IP', '')
251
+ or (request.client.host if request.client else None)
252
+ ) or None
253
+ allowed, retry_after = _check_rate_limit(int(AuthRole.MACHINE), x_internal_token, client_ip)
254
+ if not allowed:
255
+ raise HTTPException(
256
+ status_code=429,
257
+ detail="Rate limit stato privato superato",
258
+ headers={'Retry-After': str(retry_after)},
259
+ )
260
+ return AuthRole.MACHINE
261
+
262
+
263
  def require_role(min_role: AuthRole):
264
  """
265
  FastAPI Depends factory per autorizzazione granulare.
api/benchmark.py CHANGED
@@ -373,7 +373,7 @@ async def run_benchmark(
373
  #
374
  # Per ogni categoria agente (DA / ORCH / MC / REC):
375
  # 1. Inietta la context rule via UnifiedLoopPrompts._pick_context_rules()
376
- # 2. Chiama il LLM (ARCHITECT = llama-3.3-70b-versatile) a temperatura 0.3
377
  # 3. Valuta la risposta con checker regex (stessa logica di benchmark-extended.mjs)
378
  # 4. Produce score 0-100 per categoria + media totale
379
  #
 
373
  #
374
  # Per ogni categoria agente (DA / ORCH / MC / REC):
375
  # 1. Inietta la context rule via UnifiedLoopPrompts._pick_context_rules()
376
+ # 2. Chiama il LLM (ARCHITECT = openai/gpt-oss-120b) a temperatura 0.3
377
  # 3. Valuta la risposta con checker regex (stessa logica di benchmark-extended.mjs)
378
  # 4. Produce score 0-100 per categoria + media totale
379
  #
api/exec.py CHANGED
@@ -568,8 +568,8 @@ async def llm_fix_code(
568
  _FIX_CHAIN = []
569
  groq_key = os.getenv('GROQ_API_KEY', '')
570
  if groq_key:
571
- _FIX_CHAIN.append(('https://api.groq.com/openai/v1', groq_key, 'llama-3.3-70b-versatile'))
572
- _FIX_CHAIN.append(('https://api.groq.com/openai/v1', groq_key, 'llama-3.1-8b-instant'))
573
 
574
  or_key = os.getenv('OPENROUTER_API_KEY', '')
575
  if or_key:
 
568
  _FIX_CHAIN = []
569
  groq_key = os.getenv('GROQ_API_KEY', '')
570
  if groq_key:
571
+ _FIX_CHAIN.append(('https://api.groq.com/openai/v1', groq_key, 'openai/gpt-oss-120b'))
572
+ _FIX_CHAIN.append(('https://api.groq.com/openai/v1', groq_key, 'openai/gpt-oss-20b'))
573
 
574
  or_key = os.getenv('OPENROUTER_API_KEY', '')
575
  if or_key:
api/private_state.py ADDED
@@ -0,0 +1,382 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """API private per il cutover browser → backend delle tabelle Supabase sensibili.
2
+
3
+ Questi endpoint sono destinati esclusivamente alle Pages Functions, che inoltrano
4
+ ``X-Internal-Token`` al master B. Nessun client browser riceve una service-role key
5
+ o accede direttamente alle tabelle private.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ import logging
12
+ import math
13
+ import re
14
+ import time
15
+ from datetime import datetime, timedelta, timezone
16
+ from typing import Any
17
+
18
+ from fastapi import APIRouter, Depends, HTTPException, Query
19
+ from pydantic import BaseModel, Field, field_validator
20
+
21
+ from .auth_guard import require_private_state_machine
22
+ from .state import get_supabase
23
+
24
+ _logger = logging.getLogger("agente_ai.api.private_state")
25
+ router = APIRouter(
26
+ prefix="/api/private-state",
27
+ tags=["private-state"],
28
+ dependencies=[Depends(require_private_state_machine)],
29
+ )
30
+
31
+ _RAG_LANGUAGE = "rag_chunk"
32
+ _RAG_PREFIX = "__rag_chunk"
33
+ _MAX_RAG_CHUNKS = 100
34
+ _MAX_RAG_CONTENT_CHARS = 10_000
35
+ _MAX_EMBEDDING_DIMENSIONS = 4_096
36
+ _MAX_TASK_PAGE = 100
37
+
38
+
39
+ def _db() -> Any:
40
+ """Restituisce il client service-role del backend o un errore non sensibile."""
41
+ client = get_supabase()
42
+ if client is None:
43
+ raise HTTPException(status_code=503, detail="Archivio privato temporaneamente non disponibile")
44
+ return client
45
+
46
+
47
+ async def _call(operation):
48
+ """Esegue il client sincrono Supabase senza bloccare l'event loop FastAPI."""
49
+ try:
50
+ return await asyncio.to_thread(operation, _db())
51
+ except HTTPException:
52
+ raise
53
+ except Exception as exc: # Non esporre dettagli backend, query o dati al browser.
54
+ _logger.warning("[private-state] database operation failed: %s", type(exc).__name__)
55
+ raise HTTPException(status_code=502, detail="Operazione sullo stato privato non riuscita") from exc
56
+
57
+
58
+ def _json_object(value: object) -> dict[str, Any]:
59
+ if isinstance(value, dict):
60
+ return value
61
+ if isinstance(value, str):
62
+ try:
63
+ parsed = json.loads(value)
64
+ return parsed if isinstance(parsed, dict) else {}
65
+ except (TypeError, ValueError):
66
+ return {}
67
+ return {}
68
+
69
+
70
+ def _as_epoch_ms(value: object) -> int:
71
+ """Normalizza i valori `timestamptz` PostgREST in millisecondi browser-safe."""
72
+ if isinstance(value, (int, float)):
73
+ return int(value)
74
+ if isinstance(value, datetime):
75
+ moment = value
76
+ elif isinstance(value, str):
77
+ try:
78
+ moment = datetime.fromisoformat(value.replace("Z", "+00:00"))
79
+ except ValueError:
80
+ return 0
81
+ else:
82
+ return 0
83
+ if moment.tzinfo is None:
84
+ moment = moment.replace(tzinfo=timezone.utc)
85
+ return int(moment.timestamp() * 1_000)
86
+
87
+
88
+ def _finite_vector(values: list[float]) -> list[float]:
89
+ if not values or len(values) > _MAX_EMBEDDING_DIMENSIONS:
90
+ raise ValueError("dimensione embedding non valida")
91
+ if any(not math.isfinite(value) for value in values):
92
+ raise ValueError("embedding contiene valori non finiti")
93
+ return values
94
+
95
+
96
+ class TelegramConfigIn(BaseModel):
97
+ bot_token: str = Field(min_length=1, max_length=512)
98
+ chat_id: str = Field(min_length=1, max_length=128)
99
+
100
+
101
+ class SkillPatternIn(BaseModel):
102
+ id: str = Field(min_length=1, max_length=128)
103
+ task_signature: str = Field(min_length=1, max_length=200)
104
+ tool_sequence: list[str] = Field(min_length=1, max_length=8)
105
+ success_count: int = Field(ge=0, le=1_000_000)
106
+ total_count: int = Field(ge=1, le=1_000_000)
107
+ last_used: int = Field(ge=0)
108
+ confidence: float = Field(ge=0, le=1)
109
+
110
+ @field_validator("tool_sequence")
111
+ @classmethod
112
+ def validate_tools(cls, tools: list[str]) -> list[str]:
113
+ clean = [tool.strip()[:120] for tool in tools if isinstance(tool, str) and tool.strip()]
114
+ if not clean:
115
+ raise ValueError("tool_sequence non valida")
116
+ return clean
117
+
118
+
119
+ class RagChunkIn(BaseModel):
120
+ id: str = Field(min_length=1, max_length=128)
121
+ path: str = Field(min_length=1, max_length=256)
122
+ content: str = Field(min_length=51, max_length=_MAX_RAG_CONTENT_CHARS)
123
+ embedding: list[float] | None = Field(default=None, max_length=_MAX_EMBEDDING_DIMENSIONS)
124
+
125
+ @field_validator("embedding")
126
+ @classmethod
127
+ def validate_embedding(cls, value: list[float] | None) -> list[float] | None:
128
+ return _finite_vector(value) if value is not None else None
129
+
130
+
131
+ class RagIndexIn(BaseModel):
132
+ file_id: str = Field(min_length=1, max_length=40)
133
+ chunks: list[RagChunkIn] = Field(min_length=1, max_length=_MAX_RAG_CHUNKS)
134
+
135
+ @field_validator("file_id")
136
+ @classmethod
137
+ def validate_file_id(cls, value: str) -> str:
138
+ if not re.fullmatch(r"[a-z0-9_]+", value):
139
+ raise ValueError("file_id non valido")
140
+ return value
141
+
142
+
143
+ class RagSearchIn(BaseModel):
144
+ query_embedding: list[float] | None = Field(default=None, max_length=_MAX_EMBEDDING_DIMENSIONS)
145
+ query: str = Field(default="", max_length=2_000)
146
+ similarity_threshold: float = Field(default=0.22, ge=-1, le=1)
147
+ match_count: int = Field(default=5, ge=1, le=10)
148
+
149
+ @field_validator("query_embedding")
150
+ @classmethod
151
+ def validate_query_embedding(cls, value: list[float] | None) -> list[float] | None:
152
+ return _finite_vector(value) if value is not None else None
153
+
154
+ @field_validator("query")
155
+ @classmethod
156
+ def validate_query(cls, value: str) -> str:
157
+ if not value.strip() and value == "":
158
+ return ""
159
+ return value.strip()
160
+
161
+
162
+ @router.get("/sessions")
163
+ async def list_sessions(
164
+ max_age_ms: int = Query(default=300_000, ge=10_000, le=3_600_000),
165
+ limit: int = Query(default=100, ge=1, le=200),
166
+ ) -> dict[str, object]:
167
+ """Restituisce esclusivamente i metadati delle sessioni agente ancora vive."""
168
+ cutoff = (datetime.now(timezone.utc) - timedelta(milliseconds=max_age_ms)).isoformat()
169
+
170
+ def operation(client: Any):
171
+ return client.table("agent_tasks").select("task_id,context,updated_at") \
172
+ .eq("status", "__session__").gte("updated_at", cutoff) \
173
+ .order("updated_at", desc=True).limit(limit).execute()
174
+
175
+ result = await _call(operation)
176
+ sessions: list[dict[str, object]] = []
177
+ for row in result.data or []:
178
+ context = _json_object(row.get("context"))
179
+ session_id = str(context.get("sessionId") or row.get("task_id") or "").strip()
180
+ if not session_id:
181
+ continue
182
+ claimed = context.get("claimedFiles")
183
+ sessions.append({
184
+ "session_id": session_id,
185
+ "session_name": str(context.get("sessionName") or session_id)[:160],
186
+ "sprint": str(context["sprint"])[:120] if context.get("sprint") else None,
187
+ "claimed_files": [str(item)[:300] for item in claimed[:100]] if isinstance(claimed, list) else [],
188
+ "last_heartbeat": _as_epoch_ms(context.get("lastHeartbeat")) or _as_epoch_ms(row.get("updated_at")),
189
+ "current_task": str(context["currentTask"])[:500] if context.get("currentTask") else None,
190
+ })
191
+ return {"sessions": sessions}
192
+
193
+
194
+ @router.get("/tasks")
195
+ async def list_tasks(
196
+ limit: int = Query(default=20, ge=1, le=_MAX_TASK_PAGE),
197
+ offset: int = Query(default=0, ge=0, le=10_000),
198
+ status: str | None = Query(default=None, max_length=64),
199
+ ) -> dict[str, object]:
200
+ """Lista task non di configurazione per il TMA, con conteggi per stato."""
201
+ normalized_status = status.strip().upper() if status else ""
202
+
203
+ def operation(client: Any):
204
+ query = client.table("agent_tasks").select("task_id,goal,status,updated_at") \
205
+ .neq("status", "__session__").neq("status", "__config__")
206
+ if normalized_status:
207
+ query = query.eq("status", normalized_status)
208
+ page = query.order("updated_at", desc=True).range(offset, offset + limit - 1).execute()
209
+ all_statuses = client.table("agent_tasks").select("status") \
210
+ .neq("status", "__session__").neq("status", "__config__").limit(2_000).execute()
211
+ return page, all_statuses
212
+
213
+ page, all_statuses = await _call(operation)
214
+ counts: dict[str, int] = {}
215
+ for row in all_statuses.data or []:
216
+ key = str(row.get("status") or "UNKNOWN").upper()
217
+ counts[key] = counts.get(key, 0) + 1
218
+ tasks = [
219
+ {
220
+ "task_id": str(row.get("task_id") or ""),
221
+ "goal": str(row.get("goal") or "")[:1_000],
222
+ "status": str(row.get("status") or "UNKNOWN"),
223
+ "updated_at": _as_epoch_ms(row.get("updated_at")),
224
+ }
225
+ for row in page.data or []
226
+ ]
227
+ return {"tasks": tasks, "counts": counts, "offset": offset, "limit": limit}
228
+
229
+
230
+ @router.post("/telegram-config")
231
+ async def save_telegram_config(payload: TelegramConfigIn) -> dict[str, bool]:
232
+ """Salva la configurazione Telegram nel record privato del daemon."""
233
+ now = datetime.now(timezone.utc).isoformat()
234
+ row = {
235
+ "task_id": "__telegram_config__",
236
+ "goal": "__telegram_config__",
237
+ "status": "__config__",
238
+ "max_steps": 0,
239
+ "context": json.dumps({"botToken": payload.bot_token, "chatId": payload.chat_id}),
240
+ "updated_at": now,
241
+ }
242
+
243
+ def operation(client: Any):
244
+ return client.table("agent_tasks").upsert(row, on_conflict="task_id").execute()
245
+
246
+ await _call(operation)
247
+ return {"ok": True}
248
+
249
+
250
+ @router.get("/skill-patterns")
251
+ async def list_skill_patterns(limit: int = Query(default=100, ge=1, le=100)) -> dict[str, object]:
252
+ """Carica pattern cloud per il merge con lo storage locale Dexie."""
253
+
254
+ def operation(client: Any):
255
+ return client.table("skill_patterns").select(
256
+ "id,task_signature,tool_sequence,success_count,total_count,last_used,confidence"
257
+ ).order("confidence", desc=True).limit(limit).execute()
258
+
259
+ result = await _call(operation)
260
+ return {"patterns": result.data or []}
261
+
262
+
263
+ @router.put("/skill-patterns/{pattern_id}")
264
+ async def upsert_skill_pattern(pattern_id: str, payload: SkillPatternIn) -> dict[str, bool]:
265
+ """Sincronizza un pattern già validato dal layer locale del browser."""
266
+ if pattern_id != payload.id:
267
+ raise HTTPException(status_code=400, detail="Identificatore pattern non coerente")
268
+
269
+ row = payload.model_dump()
270
+
271
+ def operation(client: Any):
272
+ return client.table("skill_patterns").upsert(row, on_conflict="id").execute()
273
+
274
+ await _call(operation)
275
+ return {"ok": True}
276
+
277
+
278
+ @router.post("/rag/index")
279
+ async def index_rag(payload: RagIndexIn) -> dict[str, int]:
280
+ """Sostituisce i chunk RAG di un file senza esporre `vfs_files` al browser."""
281
+ prefix = f"{_RAG_PREFIX}/{payload.file_id}/"
282
+ rows: list[dict[str, object]] = []
283
+ now = int(time.time() * 1000)
284
+ for chunk in payload.chunks:
285
+ if not chunk.path.startswith(prefix) or not chunk.id.startswith(f"rag-{payload.file_id}-"):
286
+ raise HTTPException(status_code=400, detail="Chunk RAG non coerente con il file")
287
+ row: dict[str, object] = {
288
+ "id": chunk.id,
289
+ "user_id": "default",
290
+ "path": chunk.path,
291
+ "content": chunk.content,
292
+ "language": _RAG_LANGUAGE,
293
+ "created_at": now,
294
+ "updated_at": now,
295
+ }
296
+ if chunk.embedding:
297
+ row["embedding"] = "[" + ",".join(str(value) for value in chunk.embedding) + "]"
298
+ row["embedding_vec"] = chunk.embedding
299
+ rows.append(row)
300
+
301
+ def operation(client: Any):
302
+ client.table("vfs_files").delete().eq("language", _RAG_LANGUAGE).like("path", prefix + "%").execute()
303
+ return client.table("vfs_files").upsert(rows).execute()
304
+
305
+ await _call(operation)
306
+ return {"indexed": len(rows)}
307
+
308
+
309
+ def _parse_vector(value: object) -> list[float] | None:
310
+ if isinstance(value, list):
311
+ try:
312
+ return [float(item) for item in value]
313
+ except (TypeError, ValueError):
314
+ return None
315
+ if isinstance(value, str):
316
+ try:
317
+ parsed = json.loads(value)
318
+ return [float(item) for item in parsed] if isinstance(parsed, list) else None
319
+ except (TypeError, ValueError):
320
+ return None
321
+ return None
322
+
323
+
324
+ def _cosine_similarity(left: list[float], right: list[float]) -> float:
325
+ if len(left) != len(right) or not left:
326
+ return 0.0
327
+ numerator = sum(a * b for a, b in zip(left, right))
328
+ left_norm = math.sqrt(sum(a * a for a in left))
329
+ right_norm = math.sqrt(sum(b * b for b in right))
330
+ return numerator / (left_norm * right_norm) if left_norm and right_norm else 0.0
331
+
332
+
333
+ @router.post("/rag/search")
334
+ async def search_rag(payload: RagSearchIn) -> dict[str, object]:
335
+ """Ricerca pgvector con fallback server-side alla similarità coseno in memoria."""
336
+
337
+ def operation(client: Any):
338
+ if payload.query_embedding:
339
+ try:
340
+ rpc = client.rpc("match_rag_chunks", {
341
+ "query_embedding": payload.query_embedding,
342
+ "similarity_threshold": payload.similarity_threshold,
343
+ "match_count": payload.match_count,
344
+ }).execute()
345
+ if isinstance(rpc.data, list):
346
+ return {"mode": "pgvector", "rows": rpc.data}
347
+ except Exception as exc:
348
+ _logger.info("[private-state] rag RPC unavailable, using fallback: %s", type(exc).__name__)
349
+
350
+ result = client.table("vfs_files").select("content,embedding,path") \
351
+ .eq("language", _RAG_LANGUAGE).limit(300).execute()
352
+ query_words = {word for word in re.split(r"\W+", payload.query.lower()) if len(word) > 3}
353
+ scored: list[dict[str, object]] = []
354
+ for row in result.data or []:
355
+ content = str(row.get("content") or "")
356
+ embedding = _parse_vector(row.get("embedding"))
357
+ if payload.query_embedding and embedding:
358
+ score = _cosine_similarity(payload.query_embedding, embedding)
359
+ else:
360
+ lower = content.lower()
361
+ hits = sum(1 for word in query_words if word in lower)
362
+ score = hits / len(query_words) if query_words else 0.0
363
+ if score >= payload.similarity_threshold:
364
+ scored.append({
365
+ "content": content,
366
+ "similarity": score,
367
+ "path": str(row.get("path") or ""),
368
+ })
369
+ scored.sort(key=lambda item: float(item["similarity"]), reverse=True)
370
+ return {"mode": "cosine_fallback", "rows": scored[:payload.match_count]}
371
+
372
+ result = await _call(operation)
373
+ rows = [
374
+ {
375
+ "content": str(row.get("content") or ""),
376
+ "similarity": float(row.get("similarity") or 0),
377
+ "path": str(row.get("path") or ""),
378
+ }
379
+ for row in result["rows"]
380
+ if isinstance(row, dict)
381
+ ]
382
+ return {"results": rows, "mode": result["mode"]}
api/providers.py CHANGED
@@ -883,7 +883,7 @@ async def update_provider_models(role: AuthRole = Depends(require_role(AuthRole.
883
 
884
  # Mappa: modello_vecchio -> modello_nuovo
885
  MODEL_FIXES = [
886
- ("llama-3.1-70b-versatile", "llama-3.3-70b-versatile"),
887
  ("llama3.1-70b", "llama-4-scout"),
888
  ("llama-3.1-405b-instruct", "meta/llama-3.3-70b-instruct"),
889
  ("llama-3.1-405b", "meta-llama/llama-4-scout:free"),
 
883
 
884
  # Mappa: modello_vecchio -> modello_nuovo
885
  MODEL_FIXES = [
886
+ ("llama-3.1-70b-versatile", "openai/gpt-oss-120b"),
887
  ("llama3.1-70b", "llama-4-scout"),
888
  ("llama-3.1-405b-instruct", "meta/llama-3.3-70b-instruct"),
889
  ("llama-3.1-405b", "meta-llama/llama-4-scout:free"),
api/research.py CHANGED
@@ -283,7 +283,7 @@ async def _synthesize(topic: str, sources: list[dict]) -> str:
283
  "https://api.groq.com/openai/v1/chat/completions",
284
  headers={"Authorization": f"Bearer {groq_key}", "Content-Type": "application/json"},
285
  json={
286
- "model": "llama-3.1-8b-instant",
287
  "max_tokens": 700,
288
  "messages": [
289
  {"role": "system", "content": "Sei un assistente che sintetizza informazioni web. Rispondi sempre in italiano. Sii conciso e preciso."},
 
283
  "https://api.groq.com/openai/v1/chat/completions",
284
  headers={"Authorization": f"Bearer {groq_key}", "Content-Type": "application/json"},
285
  json={
286
+ "model": "openai/gpt-oss-20b",
287
  "max_tokens": 700,
288
  "messages": [
289
  {"role": "system", "content": "Sei un assistente che sintetizza informazioni web. Rispondi sempre in italiano. Sii conciso e preciso."},
api/speculative.py CHANGED
@@ -2,7 +2,7 @@
2
  backend/api/speculative.py — Speculative Tool Firing (S361)
3
 
4
  Pre-fires tool calls in parallel while the main model is processing.
5
- Uses Groq llama-3.1-8b-instant for ultra-fast intent extraction (~200-300ms).
6
  Results stored in a per-goal cache, consumed by _run_direct_tools before actual execution.
7
 
8
  Architecture:
@@ -141,7 +141,7 @@ def _get_spec_groq_client() -> Any:
141
 
142
  async def _extract_tools_fast(goal: str) -> list[dict]:
143
  """
144
- Usa Groq llama-3.1-8b-instant per estrarre tool calls in ~300ms.
145
  Fallback silenzioso → [] se timeout, errore o key assente.
146
  """
147
  if not os.getenv("GROQ_API_KEY"):
@@ -154,7 +154,7 @@ async def _extract_tools_fast(goal: str) -> list[dict]:
154
  resp = await asyncio.wait_for(
155
  asyncio.to_thread(
156
  client.chat.completions.create,
157
- model="llama-3.1-8b-instant",
158
  messages=[{"role": "user", "content": prompt}],
159
  temperature=0.0,
160
  max_tokens=400, # S587: 256→400 — JSON array da goal[:500] supera 256 tok
 
2
  backend/api/speculative.py — Speculative Tool Firing (S361)
3
 
4
  Pre-fires tool calls in parallel while the main model is processing.
5
+ Uses Groq openai/gpt-oss-20b for ultra-fast intent extraction (~200-300ms).
6
  Results stored in a per-goal cache, consumed by _run_direct_tools before actual execution.
7
 
8
  Architecture:
 
141
 
142
  async def _extract_tools_fast(goal: str) -> list[dict]:
143
  """
144
+ Usa Groq openai/gpt-oss-20b per estrarre tool calls in ~300ms.
145
  Fallback silenzioso → [] se timeout, errore o key assente.
146
  """
147
  if not os.getenv("GROQ_API_KEY"):
 
154
  resp = await asyncio.wait_for(
155
  asyncio.to_thread(
156
  client.chat.completions.create,
157
+ model="openai/gpt-oss-20b",
158
  messages=[{"role": "user", "content": prompt}],
159
  temperature=0.0,
160
  max_tokens=400, # S587: 256→400 — JSON array da goal[:500] supera 256 tok
api/vault.py CHANGED
@@ -95,23 +95,21 @@ def _vault_encrypt(plaintext: str) -> str:
95
 
96
 
97
  def _vault_decrypt(ciphertext: str) -> str:
98
- """Decrittografia: tenta Fernet; il fallback XOR è permesso solo per la migrazione di vecchi segreti."""
99
  if _fernet_instance:
100
  try:
101
  return _fernet_instance.decrypt(ciphertext.encode('ascii')).decode('utf-8')
102
  except Exception:
103
- _vault_logger.warning('Vault: rilevato segreto legacy (XOR) — si consiglia di risalvarlo per migrare a Fernet')
104
-
105
  if os.getenv('ENV', 'production') == 'development':
106
  return _vault_decrypt_xor(ciphertext)
107
-
108
- # In produzione, se Fernet fallisce e non siamo in dev, blocchiamo i segreti non sicuri
109
- # a meno che non sia strettamente necessario per la migrazione.
110
- try:
111
- return _vault_decrypt_xor(ciphertext)
112
- except Exception as e:
113
- _vault_logger.error(f'Vault: errore decrittografia segreto: {e}')
114
- raise HTTPException(status_code=500, detail='Vault decryption error: invalid key or corrupted data')
115
 
116
 
117
  # ── XOR legacy (usato solo come fallback per migrazione segreti esistenti) ────
@@ -246,6 +244,8 @@ async def vault_get_token(
246
  raise HTTPException(status_code=404, detail=f"Chiave '{key}' non trovata nel vault")
247
  try:
248
  return {'key': key, 'value': _vault_decrypt(data[key])}
 
 
249
  except Exception as e:
250
  raise HTTPException(status_code=500, detail=f'Decryption error: {e}')
251
 
 
95
 
96
 
97
  def _vault_decrypt(ciphertext: str) -> str:
98
+ """Decrittografa Fernet; il formato XOR legacy è ammesso solo in sviluppo per migrazione."""
99
  if _fernet_instance:
100
  try:
101
  return _fernet_instance.decrypt(ciphertext.encode('ascii')).decode('utf-8')
102
  except Exception:
103
+ _vault_logger.warning('Vault: ciphertext non-Fernet rilevato')
104
+
105
  if os.getenv('ENV', 'production') == 'development':
106
  return _vault_decrypt_xor(ciphertext)
107
+
108
+ _vault_logger.error('Vault: ciphertext legacy rifiutato in produzione')
109
+ raise HTTPException(
110
+ status_code=422,
111
+ detail='Vault decryption error: legacy ciphertext is not accepted in production',
112
+ )
 
 
113
 
114
 
115
  # ── XOR legacy (usato solo come fallback per migrazione segreti esistenti) ────
 
244
  raise HTTPException(status_code=404, detail=f"Chiave '{key}' non trovata nel vault")
245
  try:
246
  return {'key': key, 'value': _vault_decrypt(data[key])}
247
+ except HTTPException:
248
+ raise
249
  except Exception as e:
250
  raise HTTPException(status_code=500, detail=f'Decryption error: {e}')
251
 
main.py CHANGED
@@ -140,6 +140,7 @@ _ROUTER_MAP = {
140
  "marketplace": "marketplace",
141
  "plugins": "plugins",
142
  "skills": "skills",
 
143
  "auth": "auth_managed",
144
  # ── Aggiunti ROUTER-COMPLETE (29 moduli orfani rimontati) ─────────────────
145
  "agent_checkpoint": "agent_checkpoint",
 
140
  "marketplace": "marketplace",
141
  "plugins": "plugins",
142
  "skills": "skills",
143
+ "private_state": "private_state",
144
  "auth": "auth_managed",
145
  # ── Aggiunti ROUTER-COMPLETE (29 moduli orfani rimontati) ─────────────────
146
  "agent_checkpoint": "agent_checkpoint",
models/ai_client.py CHANGED
@@ -24,6 +24,20 @@ from api.semantic_cache import get_cached_response, set_cached_response
24
  import logging
25
  _logger = logging.getLogger("agente_ai")
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  @dataclass(frozen=True)
28
  class ProviderConfig:
29
  id: int = 0
@@ -40,7 +54,7 @@ class ProviderConfig:
40
  # (nessun proxy CF Worker qui: questo client gira lato backend Python, non browser).
41
  _PROVIDER_DEFS = [
42
  # tier 0 — free tier veloce e affidabile
43
- {"name": "groq", "env_key": "GROQ_API_KEY", "base_url": "https://api.groq.com/openai/v1", "model_env": "GROQ_MODEL", "default_model": "llama-3.3-70b-versatile", "tier": 0, "purpose": "reasoning"},
44
  {"name": "cerebras", "env_key": "CEREBRAS_API_KEY", "base_url": "https://api.cerebras.ai/v1", "model_env": "CEREBRAS_MODEL", "default_model": "llama-4-scout", "tier": 0, "purpose": "reasoning"},
45
  {"name": "sambanova", "env_key": "SAMBANOVA_API_KEY", "base_url": "https://api.sambanova.ai/v1", "model_env": "SAMBANOVA_MODEL", "default_model": "DeepSeek-V3.2", "tier": 0, "purpose": "reasoning"},
46
  # tier 1 — free tier con rate limit più stretti
@@ -67,6 +81,21 @@ class AIClient:
67
  providers = self._try_load_from_supabase()
68
  return providers if providers else self._discover_providers_from_env()
69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  def _try_load_from_supabase(self) -> list[ProviderConfig]:
71
  url = os.getenv("SUPABASE_URL", "")
72
  key = os.getenv("SUPABASE_SERVICE_ROLE_KEY", "")
@@ -87,7 +116,7 @@ class AIClient:
87
  return [
88
  ProviderConfig(
89
  id=row["id"], name=row["name"], api_key=row["api_key"],
90
- base_url=row["base_url"], default_model=row["default_model"],
91
  tier=row["tier"], purpose=row["purpose"], profile="general",
92
  )
93
  for row in rows
@@ -124,7 +153,9 @@ class AIClient:
124
  self._client_cache[provider.name] = OpenAI(
125
  api_key=provider.api_key,
126
  base_url=provider.base_url,
127
- timeout=20,
 
 
128
  max_retries=0
129
  )
130
  return self._client_cache[provider.name]
@@ -141,7 +172,10 @@ class AIClient:
141
  temperature=temperature,
142
  max_tokens=max_tokens
143
  ),
144
- timeout=15
 
 
 
145
  )
146
  return provider, response.choices[0].message.content or "", _time_mod.monotonic() - start
147
  except Exception as e:
@@ -188,7 +222,7 @@ class AIClient:
188
  pool = self.providers[:4]
189
 
190
  if not pool:
191
- return "🔴 Nessun provider LLM configurato (verifica le API key nei secrets del backend)."
192
 
193
  # 3. Esecuzione parallela (Ensemble Intelligence)
194
  tasks = [self._fetch_one(p, messages, temperature, max_tokens) for p in pool]
@@ -204,8 +238,8 @@ class AIClient:
204
 
205
  def _judge_best_response(self, results: List[Tuple[ProviderConfig, str, float]], target_purpose: str) -> str:
206
  valid = [(p, r, t) for p, r, t in results if not r.startswith("ERROR:") and len(r) > 10]
207
- if not valid:
208
- return "🔴 Tutti i provider configurati hanno fallito o sono saturi. Riprovo con provider esterni..."
209
 
210
  def score(item):
211
  p, r, t = item
@@ -226,16 +260,25 @@ class AIClient:
226
  # Nessun provider configurato: feedback immediato all'utente invece di
227
  # cadere silenziosamente nel loop vuoto e dare un messaggio generico.
228
  if not self.providers:
229
- yield (
230
- "⚠️ Nessun provider LLM configurato. "
231
- "Imposta almeno una delle seguenti variabili d'ambiente: "
232
- "GROQ_API_KEY, CEREBRAS_API_KEY, SAMBANOVA_API_KEY, "
233
- "OPENROUTER_API_KEY, HF_TOKEN, GEMINI_API_KEY."
234
- )
235
- return
236
 
237
- # Nello streaming proviamo i provider in ordine di tier e performance
238
- for provider in self.providers:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
  client = self._client_for(provider)
240
  try:
241
  stream = await asyncio.to_thread(
@@ -257,7 +300,7 @@ class AIClient:
257
  _logger.warning(f"Streaming fallito su {provider.name}: {e}")
258
  continue
259
 
260
- yield "🔴 Errore critico: tutti i provider configurati sono falliti o non disponibili."
261
 
262
 
263
 
 
24
  import logging
25
  _logger = logging.getLogger("agente_ai")
26
 
27
+ class ProviderUnavailableError(RuntimeError):
28
+ """Raised when no configured LLM provider can produce a response.
29
+
30
+ The error deliberately includes provider names only, never credentials or
31
+ raw upstream payloads, so callers can distinguish infrastructure failure
32
+ from a model answer without leaking sensitive data.
33
+ """
34
+
35
+ def __init__(self, providers: list[str] | tuple[str, ...]) -> None:
36
+ self.providers = tuple(providers)
37
+ detail = ", ".join(self.providers) if self.providers else "none"
38
+ super().__init__(f"provider_unavailable: {detail}")
39
+
40
+
41
  @dataclass(frozen=True)
42
  class ProviderConfig:
43
  id: int = 0
 
54
  # (nessun proxy CF Worker qui: questo client gira lato backend Python, non browser).
55
  _PROVIDER_DEFS = [
56
  # tier 0 — free tier veloce e affidabile
57
+ {"name": "groq", "env_key": "GROQ_API_KEY", "base_url": "https://api.groq.com/openai/v1", "model_env": "GROQ_MODEL", "default_model": "openai/gpt-oss-120b", "tier": 0, "purpose": "reasoning"},
58
  {"name": "cerebras", "env_key": "CEREBRAS_API_KEY", "base_url": "https://api.cerebras.ai/v1", "model_env": "CEREBRAS_MODEL", "default_model": "llama-4-scout", "tier": 0, "purpose": "reasoning"},
59
  {"name": "sambanova", "env_key": "SAMBANOVA_API_KEY", "base_url": "https://api.sambanova.ai/v1", "model_env": "SAMBANOVA_MODEL", "default_model": "DeepSeek-V3.2", "tier": 0, "purpose": "reasoning"},
60
  # tier 1 — free tier con rate limit più stretti
 
81
  providers = self._try_load_from_supabase()
82
  return providers if providers else self._discover_providers_from_env()
83
 
84
+ @staticmethod
85
+ def _runtime_model_override(row: dict) -> str:
86
+ """Use an explicit model environment override for a known provider endpoint.
87
+
88
+ Supabase remains the source for provider credentials and ordering; runtime
89
+ model-selection variables deliberately win so emergency model migrations
90
+ do not require reading or mutating provider secrets in the database.
91
+ """
92
+ database_model = str(row.get("default_model", ""))
93
+ row_base_url = str(row.get("base_url", "")).rstrip("/")
94
+ for definition in _PROVIDER_DEFS:
95
+ if row_base_url == definition["base_url"].rstrip("/"):
96
+ return os.getenv(definition["model_env"], database_model)
97
+ return database_model
98
+
99
  def _try_load_from_supabase(self) -> list[ProviderConfig]:
100
  url = os.getenv("SUPABASE_URL", "")
101
  key = os.getenv("SUPABASE_SERVICE_ROLE_KEY", "")
 
116
  return [
117
  ProviderConfig(
118
  id=row["id"], name=row["name"], api_key=row["api_key"],
119
+ base_url=row["base_url"], default_model=self._runtime_model_override(row),
120
  tier=row["tier"], purpose=row["purpose"], profile="general",
121
  )
122
  for row in rows
 
153
  self._client_cache[provider.name] = OpenAI(
154
  api_key=provider.api_key,
155
  base_url=provider.base_url,
156
+ # I task coding possono richiedere più di 20 s prima del primo
157
+ # chunk dal fallback gratuito; il budget esterno resta finito.
158
+ timeout=45,
159
  max_retries=0
160
  )
161
  return self._client_cache[provider.name]
 
172
  temperature=temperature,
173
  max_tokens=max_tokens
174
  ),
175
+ # Il fallback non-streaming deve avere lo stesso budget del client:
176
+ # 15s scartava provider sani su richieste coding che richiedono
177
+ # più tempo per produrre una risposta completa dopo uno stream interrotto.
178
+ timeout=45
179
  )
180
  return provider, response.choices[0].message.content or "", _time_mod.monotonic() - start
181
  except Exception as e:
 
222
  pool = self.providers[:4]
223
 
224
  if not pool:
225
+ raise ProviderUnavailableError([])
226
 
227
  # 3. Esecuzione parallela (Ensemble Intelligence)
228
  tasks = [self._fetch_one(p, messages, temperature, max_tokens) for p in pool]
 
238
 
239
  def _judge_best_response(self, results: List[Tuple[ProviderConfig, str, float]], target_purpose: str) -> str:
240
  valid = [(p, r, t) for p, r, t in results if not r.startswith("ERROR:") and len(r) > 10]
241
+ if not valid:
242
+ raise ProviderUnavailableError([p.name for p, _r, _t in results])
243
 
244
  def score(item):
245
  p, r, t = item
 
260
  # Nessun provider configurato: feedback immediato all'utente invece di
261
  # cadere silenziosamente nel loop vuoto e dare un messaggio generico.
262
  if not self.providers:
263
+ raise ProviderUnavailableError([])
 
 
 
 
 
 
264
 
265
+ # Un client di ruolo può contenere un solo provider specializzato.
266
+ # Dopo il suo primario, integra la flotta runtime non duplicata: un limite
267
+ # temporaneo di quel provider non deve rendere indisponibile l'intero task.
268
+ providers = list(self.providers)
269
+ try:
270
+ for fallback in self._load_providers():
271
+ if not any(
272
+ current.name == fallback.name
273
+ and current.base_url == fallback.base_url
274
+ for current in providers
275
+ ):
276
+ providers.append(fallback)
277
+ except Exception as exc:
278
+ _logger.debug("Streaming fleet expansion skipped: %s", type(exc).__name__)
279
+
280
+ # Nello streaming proviamo il primario di ruolo, poi i fallback runtime.
281
+ for provider in providers:
282
  client = self._client_for(provider)
283
  try:
284
  stream = await asyncio.to_thread(
 
300
  _logger.warning(f"Streaming fallito su {provider.name}: {e}")
301
  continue
302
 
303
+ raise ProviderUnavailableError([provider.name for provider in providers])
304
 
305
 
306
 
models/role_router.py CHANGED
@@ -38,9 +38,9 @@ _logger = logging.getLogger("models.role_router")
38
  class Role(str, Enum):
39
  FAST = "fast" # greetings, math semplice, identity — openai/gpt-oss-20b
40
  ARCHITECT = "architect" # planning, ragionamento complesso — llama-4-scout (10M ctx)
41
- CODER = "coder" # coding, debug — llama-3.3-70b-versatile
42
- TESTER = "tester" # test gen, debug hints — llama-3.3-70b-versatile
43
- CONTEXT = "context" # summarization, context compression — llama-3.3-70b-versatile
44
  DEFAULT = "default" # AIClient() primary
45
  RESEARCHER = "researcher" # web research + document synthesis — gemini-2.0-flash-exp
46
  REASONER = "reasoner" # throughput massimo — Cerebras llama-4-scout (2000+ tok/s)
@@ -85,7 +85,7 @@ class RoleRouter:
85
 
86
  @staticmethod
87
  def _fast_client() -> Any:
88
- """Groq llama-3.3-70b-versatile 344ms TTFT, 100% benchmark qualità.
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")
@@ -96,7 +96,7 @@ class RoleRouter:
96
  name="groq-fast",
97
  api_key=groq_key,
98
  base_url="https://api.groq.com/openai/v1",
99
- default_model=os.getenv("GROQ_FAST_MODEL", "llama-3.3-70b-versatile"),
100
  )
101
  rest = [p for p in client.providers if p.name not in ("groq", "groq-fast", "groq-tester")]
102
  client.providers = [fast, *rest]
@@ -161,12 +161,12 @@ class RoleRouter:
161
 
162
  @staticmethod
163
  def _coder_client() -> Any:
164
- """Groq llama-3.3-70b-versatile 358ms TTFT, 100% benchmark qualità.
165
- AGGIORNATO 2026-08-04: era Groq openai/gpt-oss-120b.
166
- Fallback: OpenRouter llama-4-scout:free se GROQ_API_KEY mancante."""
167
  from models.ai_client import AIClient, ProviderConfig
168
  groq_key = os.getenv("GROQ_API_KEY")
169
- model = os.getenv("CODER_MODEL", "llama-3.3-70b-versatile")
 
170
  if groq_key:
171
  client = AIClient()
172
  coder = ProviderConfig(
@@ -174,9 +174,26 @@ class RoleRouter:
174
  api_key=groq_key,
175
  base_url="https://api.groq.com/openai/v1",
176
  default_model=model,
 
177
  )
178
- rest = [p for p in client.providers if p.name not in ("groq", "groq-coder")]
179
- client.providers = [coder, *rest]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  client.provider_name = coder.name
181
  client.default_model = coder.default_model
182
  client.client = client._client_for(coder)
@@ -189,6 +206,7 @@ class RoleRouter:
189
  api_key=openrouter_key,
190
  base_url="https://openrouter.ai/api/v1",
191
  default_model="meta-llama/llama-4-scout:free",
 
192
  )
193
  rest = [p for p in client.providers if not p.name.startswith("openrouter")]
194
  client.providers = [fallback, *rest]
@@ -200,48 +218,66 @@ class RoleRouter:
200
 
201
  @staticmethod
202
  def _researcher_client() -> Any:
203
- """Gemini 2.0-flash-exp TTFT 910ms, ottima per research/synthesis/doc analysis."""
 
 
 
 
 
204
  from models.ai_client import AIClient, ProviderConfig
205
- gemini_key = os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
206
  groq_key = os.getenv("GROQ_API_KEY")
207
-
208
- if gemini_key:
209
  client = AIClient()
210
  researcher = ProviderConfig(
211
- name="gemini-researcher",
212
- api_key=gemini_key,
213
- base_url="https://generativelanguage.googleapis.com/v1beta/openai",
214
- default_model=os.getenv("GEMINI_MODEL", "gemini-2.0-flash-exp"),
 
 
 
215
  )
216
- rest = [p for p in client.providers if not p.name.startswith("gemini")]
 
 
 
217
  client.providers = [researcher, *rest]
218
  client.provider_name = researcher.name
219
  client.default_model = researcher.default_model
220
  client.client = client._client_for(researcher)
221
  return client
222
- elif groq_key:
 
 
 
 
 
 
 
 
 
 
 
223
  client = AIClient()
224
- groq_compound = ProviderConfig(
225
- name="groq-compound-researcher",
226
  api_key=groq_key,
227
  base_url="https://api.groq.com/openai/v1",
228
- default_model=os.getenv("GROQ_COMPOUND_MODEL", "groq/compound"),
 
 
 
229
  )
230
- rest = [p for p in client.providers if p.name not in ("groq", "groq-compound-researcher")]
231
- client.providers = [groq_compound, *rest]
232
- client.provider_name = groq_compound.name
233
- client.default_model = groq_compound.default_model
234
- client.client = client._client_for(groq_compound)
 
 
 
235
  return client
236
- return AIClient()
237
 
238
- @staticmethod
239
- def _reasoner_client() -> Any:
240
- """Cerebras llama-4-scout — 207ms TTFT, 100% qualità (bench 2026-08-04).
241
- REASONING MODEL: genera "reasoning" field prima del "content".
242
- Richiede max_tokens≥500 per output non-vuoto su task non-triviali.
243
- Fallback: _coder_client (Groq 70B) se CEREBRAS_API_KEY mancante."""
244
- from models.ai_client import AIClient, ProviderConfig
245
  cerebras_key = os.getenv("CEREBRAS_API_KEY")
246
  if not cerebras_key:
247
  return RoleRouter._coder_client()
@@ -252,7 +288,7 @@ class RoleRouter:
252
  base_url="https://api.cerebras.ai/v1",
253
  default_model=os.getenv("CEREBRAS_MODEL", "llama-4-scout"),
254
  )
255
- rest = [p for p in client.providers if not p.name.startswith("cerebras")]
256
  client.providers = [reasoner, *rest]
257
  client.provider_name = reasoner.name
258
  client.default_model = reasoner.default_model
@@ -306,7 +342,7 @@ class RoleRouter:
306
 
307
  @staticmethod
308
  def _tester_client() -> Any:
309
- """Groq llama-3.3-70b-versatile fast, sufficiente per test gen e debug hints."""
310
  from models.ai_client import AIClient, ProviderConfig
311
  groq_key = os.getenv("GROQ_API_KEY")
312
  if not groq_key:
@@ -316,7 +352,8 @@ class RoleRouter:
316
  name="groq-tester",
317
  api_key=groq_key,
318
  base_url="https://api.groq.com/openai/v1",
319
- default_model=os.getenv("GROQ_FAST_MODEL", "llama-3.3-70b-versatile"),
 
320
  )
321
  rest = [p for p in client.providers if p.name not in ("groq", "groq-tester")]
322
  client.providers = [tester, *rest]
 
38
  class Role(str, Enum):
39
  FAST = "fast" # greetings, math semplice, identity — openai/gpt-oss-20b
40
  ARCHITECT = "architect" # planning, ragionamento complesso — llama-4-scout (10M ctx)
41
+ CODER = "coder" # coding, debug — openai/gpt-oss-120b
42
+ TESTER = "tester" # test gen, debug hints — openai/gpt-oss-120b
43
+ CONTEXT = "context" # summarization, context compression — openai/gpt-oss-120b
44
  DEFAULT = "default" # AIClient() primary
45
  RESEARCHER = "researcher" # web research + document synthesis — gemini-2.0-flash-exp
46
  REASONER = "reasoner" # throughput massimo — Cerebras llama-4-scout (2000+ tok/s)
 
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")
 
96
  name="groq-fast",
97
  api_key=groq_key,
98
  base_url="https://api.groq.com/openai/v1",
99
+ default_model=os.getenv("GROQ_FAST_MODEL", "openai/gpt-oss-20b"),
100
  )
101
  rest = [p for p in client.providers if p.name not in ("groq", "groq-fast", "groq-tester")]
102
  client.providers = [fast, *rest]
 
161
 
162
  @staticmethod
163
  def _coder_client() -> Any:
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")
170
  if groq_key:
171
  client = AIClient()
172
  coder = ProviderConfig(
 
174
  api_key=groq_key,
175
  base_url="https://api.groq.com/openai/v1",
176
  default_model=model,
177
+ purpose="coding",
178
  )
179
+ dedicated_fallbacks: list[ProviderConfig] = []
180
+ if nvidia_key:
181
+ dedicated_fallbacks.append(
182
+ ProviderConfig(
183
+ name="nvidia-coder",
184
+ api_key=nvidia_key,
185
+ base_url="https://integrate.api.nvidia.com/v1",
186
+ default_model=os.getenv(
187
+ "NVIDIA_MODEL", "nvidia/nemotron-3-ultra-550b-a55b"
188
+ ),
189
+ purpose="coding",
190
+ )
191
+ )
192
+ rest = [
193
+ provider for provider in client.providers
194
+ if provider.name not in ("groq", "groq-coder", "nvidia", "nvidia-coder")
195
+ ]
196
+ client.providers = [coder, *dedicated_fallbacks, *rest]
197
  client.provider_name = coder.name
198
  client.default_model = coder.default_model
199
  client.client = client._client_for(coder)
 
206
  api_key=openrouter_key,
207
  base_url="https://openrouter.ai/api/v1",
208
  default_model="meta-llama/llama-4-scout:free",
209
+ purpose="coding",
210
  )
211
  rest = [p for p in client.providers if not p.name.startswith("openrouter")]
212
  client.providers = [fallback, *rest]
 
218
 
219
  @staticmethod
220
  def _researcher_client() -> Any:
221
+ """Groq GPT-OSS 120B per analisi e sintesi; la flotta restante è fallback.
222
+
223
+ Gemini può essere configurato ma ha una quota indipendente e più stretta:
224
+ non deve quindi bloccare i task della persona analyst/researcher quando
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,
234
+ base_url="https://api.groq.com/openai/v1",
235
+ default_model=os.getenv(
236
+ "GROQ_RESEARCH_MODEL",
237
+ os.getenv("GROQ_MODEL", "openai/gpt-oss-120b"),
238
+ ),
239
  )
240
+ rest = [
241
+ provider for provider in client.providers
242
+ if provider.name not in ("groq", "groq-researcher")
243
+ ]
244
  client.providers = [researcher, *rest]
245
  client.provider_name = researcher.name
246
  client.default_model = researcher.default_model
247
  client.client = client._client_for(researcher)
248
  return client
249
+ return AIClient()
250
+
251
+ @staticmethod
252
+ def _reasoner_client() -> Any:
253
+ """Priorità a Groq GPT-OSS per reasoning/MMLU, con flotta runtime come fallback.
254
+
255
+ Gemini è soggetto a quote RPM e non deve essere il percorso iniziale per
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,
265
  base_url="https://api.groq.com/openai/v1",
266
+ default_model=os.getenv(
267
+ "GROQ_REASONER_MODEL",
268
+ os.getenv("GROQ_MODEL", "openai/gpt-oss-120b"),
269
+ ),
270
  )
271
+ rest = [
272
+ provider for provider in client.providers
273
+ if provider.name not in ("groq", "groq-reasoner")
274
+ ]
275
+ client.providers = [reasoner, *rest]
276
+ client.provider_name = reasoner.name
277
+ client.default_model = reasoner.default_model
278
+ client.client = client._client_for(reasoner)
279
  return client
 
280
 
 
 
 
 
 
 
 
281
  cerebras_key = os.getenv("CEREBRAS_API_KEY")
282
  if not cerebras_key:
283
  return RoleRouter._coder_client()
 
288
  base_url="https://api.cerebras.ai/v1",
289
  default_model=os.getenv("CEREBRAS_MODEL", "llama-4-scout"),
290
  )
291
+ rest = [provider for provider in client.providers if not provider.name.startswith("cerebras")]
292
  client.providers = [reasoner, *rest]
293
  client.provider_name = reasoner.name
294
  client.default_model = reasoner.default_model
 
342
 
343
  @staticmethod
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:
 
352
  name="groq-tester",
353
  api_key=groq_key,
354
  base_url="https://api.groq.com/openai/v1",
355
+ default_model=os.getenv("GROQ_FAST_MODEL", "openai/gpt-oss-20b"),
356
+ purpose="coding",
357
  )
358
  rest = [p for p in client.providers if p.name not in ("groq", "groq-tester")]
359
  client.providers = [tester, *rest]
tests/test_ai_client_provider_unavailability.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import unittest
3
+ from unittest.mock import patch
4
+
5
+ from models.ai_client import AIClient, ProviderConfig, ProviderUnavailableError
6
+
7
+
8
+ class _FailingCompletions:
9
+ def create(self, **_kwargs):
10
+ raise RuntimeError("quota exhausted")
11
+
12
+
13
+ class _FailingChat:
14
+ completions = _FailingCompletions()
15
+
16
+
17
+ class _FailingClient:
18
+ chat = _FailingChat()
19
+
20
+
21
+ class _ClientWithFailingProviders(AIClient):
22
+ def __init__(self):
23
+ self.providers = [
24
+ ProviderConfig(name="primary", api_key="x", base_url="https://example.invalid", default_model="model-a"),
25
+ ProviderConfig(name="fallback", api_key="y", base_url="https://example.invalid", default_model="model-b"),
26
+ ]
27
+ self._client_cache = {}
28
+ self._rr_indices = {}
29
+
30
+ def _client_for(self, _provider):
31
+ return _FailingClient()
32
+
33
+
34
+ class ProviderUnavailableTests(unittest.IsolatedAsyncioTestCase):
35
+ async def test_chat_raises_structured_error_when_every_provider_fails(self):
36
+ client = _ClientWithFailingProviders()
37
+
38
+ with self.assertRaises(ProviderUnavailableError) as raised:
39
+ await client.chat([{"role": "user", "content": "hello"}], max_tokens=8)
40
+
41
+ self.assertEqual(raised.exception.providers, ("primary", "fallback"))
42
+ self.assertNotIn("api_key", str(raised.exception).lower())
43
+
44
+ async def test_stream_chat_raises_structured_error_when_every_provider_fails(self):
45
+ client = _ClientWithFailingProviders()
46
+
47
+ with self.assertRaises(ProviderUnavailableError) as raised:
48
+ async for _ in client.stream_chat([{"role": "user", "content": "hello"}], max_tokens=8):
49
+ pass
50
+
51
+ self.assertEqual(raised.exception.providers, ("primary", "fallback"))
52
+ self.assertNotIn("api_key", str(raised.exception).lower())
53
+
54
+ async def test_stream_chat_expands_a_role_specific_provider_pool(self):
55
+ client = _ClientWithFailingProviders()
56
+ client.providers = [
57
+ ProviderConfig(name="gemini-role", api_key="x", base_url="https://example.invalid", default_model="gemini")
58
+ ]
59
+ runtime_fallback = ProviderConfig(
60
+ name="nvidia", api_key="y", base_url="https://fallback.invalid", default_model="nemotron"
61
+ )
62
+
63
+ with patch.object(client, "_load_providers", return_value=[runtime_fallback]):
64
+ with self.assertRaises(ProviderUnavailableError) as raised:
65
+ async for _ in client.stream_chat([{"role": "user", "content": "hello"}], max_tokens=8):
66
+ pass
67
+
68
+ self.assertEqual(raised.exception.providers, ("gemini-role", "nvidia"))
69
+
70
+
71
+ class RuntimeModelOverrideTests(unittest.TestCase):
72
+ def test_groq_runtime_model_overrides_database_model(self):
73
+ row = {
74
+ "base_url": "https://api.groq.com/openai/v1",
75
+ "default_model": "llama-3.3-70b-versatile",
76
+ }
77
+ with patch.dict("os.environ", {"GROQ_MODEL": "openai/gpt-oss-120b"}, clear=False):
78
+ self.assertEqual(
79
+ AIClient._runtime_model_override(row),
80
+ "openai/gpt-oss-120b",
81
+ )
82
+
83
+ def test_unknown_provider_keeps_database_model(self):
84
+ row = {"base_url": "https://example.invalid/v1", "default_model": "custom-model"}
85
+ self.assertEqual(AIClient._runtime_model_override(row), "custom-model")
86
+
87
+
88
+ if __name__ == "__main__":
89
+ unittest.main()
tests/test_coding_output_contract.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+
3
+ from agents.unified_loop_llm import LLMSelectionMixin
4
+
5
+
6
+ class CodingOutputContractTests(unittest.TestCase):
7
+ def test_code_directive_requires_extractable_single_snippet(self):
8
+ directive = LLMSelectionMixin._FORMAT_DIRECTIVE_CODE
9
+
10
+ self.assertIn("ESATTAMENTE un blocco", directive)
11
+ self.assertIn("linguaggio richiesto", directive)
12
+ self.assertIn("compilabile", directive)
13
+ self.assertIn("export", directive)
14
+
15
+ def test_code_directive_covers_async_and_react_safety(self):
16
+ directive = LLMSelectionMixin._FORMAT_DIRECTIVE_CODE
17
+
18
+ self.assertIn("async", directive)
19
+ self.assertIn("await", directive)
20
+ self.assertIn("try/catch", directive)
21
+ self.assertIn("Promise.allSettled", directive)
22
+ self.assertIn("AbortController", directive)
23
+ self.assertIn("return () =>", directive)
24
+
25
+
26
+ if __name__ == "__main__":
27
+ unittest.main()
tests/test_private_state_contract.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ import unittest
3
+
4
+ from api.auth_guard import require_private_state_machine
5
+ from api.private_state import RagSearchIn, SkillPatternIn, TelegramConfigIn, _as_epoch_ms, _cosine_similarity, _parse_vector, router
6
+
7
+
8
+ class PrivateStateContractTests(unittest.TestCase):
9
+ def test_router_is_namespaced_under_private_state(self):
10
+ self.assertEqual(router.prefix, "/api/private-state")
11
+ paths = {route.path for route in router.routes}
12
+ self.assertIn("/api/private-state/sessions", paths)
13
+ self.assertIn("/api/private-state/tasks", paths)
14
+ self.assertIn("/api/private-state/rag/index", paths)
15
+ self.assertIn("/api/private-state/rag/search", paths)
16
+ self.assertEqual(router.dependencies[0].dependency, require_private_state_machine)
17
+
18
+ def test_rag_search_allows_lexical_fallback_without_embedding(self):
19
+ payload = RagSearchIn(query="contesto progetto", query_embedding=None)
20
+ self.assertEqual(payload.query, "contesto progetto")
21
+ self.assertIsNone(payload.query_embedding)
22
+
23
+ def test_rag_search_rejects_non_finite_embedding(self):
24
+ with self.assertRaises(ValueError):
25
+ RagSearchIn(query_embedding=[1.0, math.inf])
26
+
27
+ def test_skill_pattern_rejects_empty_tool_sequence(self):
28
+ with self.assertRaises(ValueError):
29
+ SkillPatternIn(
30
+ id="pattern", task_signature="task", tool_sequence=[" "],
31
+ success_count=0, total_count=1, last_used=1, confidence=0,
32
+ )
33
+
34
+ def test_timestamp_is_normalized_for_browser_clients(self):
35
+ self.assertEqual(_as_epoch_ms("1970-01-01T00:00:01+00:00"), 1_000)
36
+ self.assertEqual(_as_epoch_ms("not-a-timestamp"), 0)
37
+
38
+ def test_private_payload_has_bounded_required_fields(self):
39
+ config = TelegramConfigIn(bot_token="token", chat_id="chat")
40
+ self.assertEqual(config.chat_id, "chat")
41
+ self.assertEqual(_parse_vector("[1, 2]"), [1.0, 2.0])
42
+ self.assertAlmostEqual(_cosine_similarity([1.0, 0.0], [1.0, 0.0]), 1.0)
43
+
44
+
45
+ if __name__ == "__main__":
46
+ unittest.main()
tests/test_role_router_researcher.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import unittest
3
+ from unittest.mock import patch
4
+
5
+ from models.ai_client import AIClient
6
+ from models.role_router import Role, RoleRouter
7
+
8
+
9
+ class ResearcherRoleRoutingTests(unittest.TestCase):
10
+ @patch.object(AIClient, "_load_providers", return_value=[])
11
+ @patch.dict(
12
+ os.environ,
13
+ {
14
+ "GROQ_API_KEY": "test-groq-key",
15
+ "GROQ_MODEL": "openai/gpt-oss-120b",
16
+ "GEMINI_API_KEY": "test-gemini-key",
17
+ },
18
+ clear=False,
19
+ )
20
+ def test_researcher_prefers_groq_gpt_oss_when_available(self, _load_providers):
21
+ client = RoleRouter.get_client(Role.RESEARCHER)
22
+
23
+ self.assertEqual(client.provider_name, "groq-researcher")
24
+ self.assertEqual(client.default_model, "openai/gpt-oss-120b")
25
+ self.assertEqual(client.providers[0].name, "groq-researcher")
26
+ self.assertTrue(
27
+ all(not provider.name.startswith("gemini") for provider in client.providers)
28
+ )
29
+
30
+ @patch.object(AIClient, "_load_providers", return_value=[])
31
+ @patch.dict(
32
+ os.environ,
33
+ {
34
+ "GROQ_API_KEY": "test-groq-key",
35
+ "GROQ_MODEL": "openai/gpt-oss-120b",
36
+ "GEMINI_API_KEY": "test-gemini-key",
37
+ },
38
+ clear=False,
39
+ )
40
+ def test_reasoner_prefers_groq_gpt_oss_when_available(self, _load_providers):
41
+ client = RoleRouter.get_client(Role.REASONER)
42
+
43
+ self.assertEqual(client.provider_name, "groq-reasoner")
44
+ self.assertEqual(client.default_model, "openai/gpt-oss-120b")
45
+ self.assertEqual(client.providers[0].name, "groq-reasoner")
46
+
47
+ @patch.object(AIClient, "_load_providers", return_value=[])
48
+ @patch.dict(
49
+ os.environ,
50
+ {
51
+ "GROQ_API_KEY": "test-groq-key",
52
+ "CODER_MODEL": "openai/gpt-oss-120b",
53
+ "NVIDIA_API_KEY": "test-nvidia-key",
54
+ "NVIDIA_MODEL": "nvidia/nemotron-3-ultra-550b-a55b",
55
+ },
56
+ clear=False,
57
+ )
58
+ def test_coder_gpt_oss_is_in_the_coding_pool(self, _load_providers):
59
+ client = RoleRouter.get_client(Role.CODER)
60
+
61
+ self.assertEqual(client.provider_name, "groq-coder")
62
+ self.assertEqual(client.providers[0].purpose, "coding")
63
+ self.assertEqual(
64
+ [provider.name for provider in client.providers],
65
+ ["groq-coder", "nvidia-coder"],
66
+ )
67
+
68
+
69
+ if __name__ == "__main__":
70
+ unittest.main()