Spaces:
Running
Running
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 +1 -1
- agents/unified_loop_llm.py +13 -6
- agents/unified_loop_prompts.py +3 -3
- api/auth_guard.py +32 -0
- api/benchmark.py +1 -1
- api/exec.py +2 -2
- api/private_state.py +382 -0
- api/providers.py +1 -1
- api/research.py +1 -1
- api/speculative.py +3 -3
- api/vault.py +11 -11
- main.py +1 -0
- models/ai_client.py +60 -17
- models/role_router.py +77 -40
- tests/test_ai_client_provider_unavailability.py +89 -0
- tests/test_coding_output_contract.py +27 -0
- tests/test_private_state_contract.py +46 -0
- tests/test_role_router_researcher.py +70 -0
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
|
| 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
|
| 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 |
-
"â¢
|
| 288 |
-
"
|
| 289 |
-
"â¢
|
| 290 |
-
"
|
| 291 |
-
"â¢
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 =
|
| 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, '
|
| 572 |
-
_FIX_CHAIN.append(('https://api.groq.com/openai/v1', groq_key, '
|
| 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", "
|
| 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": "
|
| 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
|
| 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
|
| 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="
|
| 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 |
-
"""
|
| 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:
|
| 104 |
-
|
| 105 |
if os.getenv('ENV', 'production') == 'development':
|
| 106 |
return _vault_decrypt_xor(ciphertext)
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 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": "
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
#
|
| 238 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 —
|
| 42 |
-
TESTER = "tester" # test gen, debug hints —
|
| 43 |
-
CONTEXT = "context" # summarization, context compression —
|
| 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
|
| 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", "
|
| 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
|
| 165 |
-
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 179 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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="
|
| 212 |
-
api_key=
|
| 213 |
-
base_url="https://
|
| 214 |
-
default_model=os.getenv(
|
|
|
|
|
|
|
|
|
|
| 215 |
)
|
| 216 |
-
rest = [
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
client = AIClient()
|
| 224 |
-
|
| 225 |
-
name="groq-
|
| 226 |
api_key=groq_key,
|
| 227 |
base_url="https://api.groq.com/openai/v1",
|
| 228 |
-
default_model=os.getenv(
|
|
|
|
|
|
|
|
|
|
| 229 |
)
|
| 230 |
-
rest = [
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
client.
|
|
|
|
|
|
|
|
|
|
| 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 = [
|
| 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
|
| 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", "
|
|
|
|
| 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()
|