sync: 176 file da Baida98/AI@eeb3eb47 (2026-08-25 09:19 UTC) [deploy-all]

#32
by Baida07 - opened
agents/planner.py CHANGED
@@ -194,6 +194,7 @@ def _parse_plan(raw: str) -> dict | None:
194
 
195
  class Planner:
196
  def __init__(self, llm_client: AIClient | None = None):
 
197
  if llm_client is not None:
198
  self.llm = llm_client
199
  else:
@@ -211,6 +212,8 @@ class Planner:
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
 
194
 
195
  class Planner:
196
  def __init__(self, llm_client: AIClient | None = None):
197
+ self._explicit_llm = llm_client is not None
198
  if llm_client is not None:
199
  self.llm = llm_client
200
  else:
 
212
  def _get_fast_llm(self) -> AIClient:
213
  """Gap-5: Cerebras gpt-oss-120b (2000+ tok/s) per quick-start draft.
214
  Fallback: Groq openai/gpt-oss-20b se CEREBRAS_API_KEY assente."""
215
+ if self._explicit_llm:
216
+ return self.llm
217
  try:
218
  from models.role_router import RoleRouter, Role
219
  return RoleRouter.get_client(Role.REASONER) # Cerebras 120B
api/agent.py CHANGED
@@ -45,7 +45,7 @@ from .auth_guard import require_role, AuthRole
45
  from pydantic import BaseModel, field_validator
46
  from typing import Literal
47
  from .state import (
48
- _agent_tasks, _task_checkpoints, _loop_registry, _run_stream_tasks,
49
  _prune_agent_tasks, _prune_checkpoints, _prune_loop_registry,
50
  _get_mem_manager, _get_mem_manager_async, _get_executor, _get_planner, _get_ai_client,
51
  ReasonLoopIn, AgentTaskIn,
@@ -87,6 +87,21 @@ except Exception:
87
  router = APIRouter()
88
 
89
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  # ── Deprecated run_loop ────────────────────────────────────────────────────────
91
 
92
  @router.post('/run_loop', deprecated=True)
@@ -601,6 +616,8 @@ async def create_agent_task(body: AgentTaskIn, role: AuthRole = Depends(require_
601
 
602
  # Already in memory → return immediately (normal path, includes S358 reconnect)
603
  if task_id in _agent_tasks:
 
 
604
  return {'taskId': task_id, 'status': _agent_tasks[task_id]['status']}
605
 
606
  # S359: try Supabase lazy restore (only hit network after backend restart)
@@ -610,6 +627,7 @@ async def create_agent_task(body: AgentTaskIn, role: AuthRole = Depends(require_
610
  # Use context from the incoming request (not persisted to save space).
611
  restored['context'] = body.context
612
  _agent_tasks[task_id] = restored
 
613
  return {'taskId': task_id, 'status': restored['status'], 'restored': True}
614
 
615
  # Brand new task
@@ -627,6 +645,10 @@ async def create_agent_task(body: AgentTaskIn, role: AuthRole = Depends(require_
627
  'persona': body.persona, # P17-F5: expertise persona hint
628
  'session_id': body.session_id or '', # P17-F2: BB session key (normalize None→'')
629
  }
 
 
 
 
630
  # BG-4: restore cross-session handoff context (async, non-blocking)
631
  if body.session_id:
632
  _hctx = await sb_restore_handoff_context(body.session_id)
@@ -735,6 +757,7 @@ async def list_agent_tasks(limit: int = 50, status: str = '', role: AuthRole = D
735
  async def cancel_agent_task(task_id: str, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
736
  if task_id in _agent_tasks:
737
  _agent_tasks[task_id]['status'] = 'CANCELLED'
 
738
  reg = _loop_registry.get(task_id)
739
  if reg and not reg.get('done'):
740
  at = reg.get('asyncio_task')
@@ -947,8 +970,9 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
947
  async def run_loop() -> None:
948
  try:
949
  from agents.unified_loop import UnifiedAgentLoop
950
- # S388: singleton evita OpenAI() per ogni task
951
- client = _get_ai_client()
 
952
  try:
953
  from agents.critic import Critic
954
  from agents.response_verifier import ResponseVerifier
@@ -1051,11 +1075,20 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
1051
  _hctx = task.get("_handoff_context", "")
1052
  if _hctx:
1053
  context_str = f"{_hctx}\n\n{context_str}".strip()
1054
- # P17-F5: route primary LLM to persona-appropriate client
1055
- _persona_client = _get_persona_llm_client(_persona, client)
 
 
 
 
 
 
 
 
 
1056
  loop = UnifiedAgentLoop(
1057
  llm_client=_persona_client, critic=_critic, verifier=_verifier,
1058
- memory=await _get_mem_manager_async(), executor=_get_executor(), planner=_get_planner(),
1059
  )
1060
  step_idx = [0]
1061
  _backend_steps: list[dict] = [] # GAP-SYNC-FIX: log step per resume preciso
@@ -1347,6 +1380,9 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
1347
  asyncio.create_task(_tg_error(task_id, task.get('goal', ''), str(err))).add_done_callback(_log_task_exc)
1348
 
1349
  finally:
 
 
 
1350
  reg_entry['done'] = True
1351
  reg_entry['finished_at'] = time.time()
1352
  for q in list(reg_entry['subscriber_queues']):
 
45
  from pydantic import BaseModel, field_validator
46
  from typing import Literal
47
  from .state import (
48
+ _agent_tasks, _task_ai_clients, _task_checkpoints, _loop_registry, _run_stream_tasks,
49
  _prune_agent_tasks, _prune_checkpoints, _prune_loop_registry,
50
  _get_mem_manager, _get_mem_manager_async, _get_executor, _get_planner, _get_ai_client,
51
  ReasonLoopIn, AgentTaskIn,
 
87
  router = APIRouter()
88
 
89
 
90
+ def _attach_byok_client(task_id: str, credentials: object) -> None:
91
+ """Create a task-scoped LLM client without persisting or logging credentials."""
92
+ if credentials is None:
93
+ return
94
+ try:
95
+ runtime_config = credentials.as_runtime_config()
96
+ if not runtime_config:
97
+ return
98
+ from models.ai_client import AIClient
99
+ _task_ai_clients[task_id] = AIClient(byok_credentials=runtime_config)
100
+ except Exception as exc:
101
+ # Never include the payload or credential values in diagnostics.
102
+ _logger.warning("[agent] unable to initialise BYOK task client: %s", type(exc).__name__)
103
+
104
+
105
  # ── Deprecated run_loop ────────────────────────────────────────────────────────
106
 
107
  @router.post('/run_loop', deprecated=True)
 
616
 
617
  # Already in memory → return immediately (normal path, includes S358 reconnect)
618
  if task_id in _agent_tasks:
619
+ if task_id not in _task_ai_clients:
620
+ _attach_byok_client(task_id, body.provider_credentials)
621
  return {'taskId': task_id, 'status': _agent_tasks[task_id]['status']}
622
 
623
  # S359: try Supabase lazy restore (only hit network after backend restart)
 
627
  # Use context from the incoming request (not persisted to save space).
628
  restored['context'] = body.context
629
  _agent_tasks[task_id] = restored
630
+ _attach_byok_client(task_id, body.provider_credentials)
631
  return {'taskId': task_id, 'status': restored['status'], 'restored': True}
632
 
633
  # Brand new task
 
645
  'persona': body.persona, # P17-F5: expertise persona hint
646
  'session_id': body.session_id or '', # P17-F2: BB session key (normalize None→'')
647
  }
648
+ # Le credenziali BYOK restano in una mappa runtime separata dai metadata task
649
+ # e non raggiungono Supabase, checkpoint o buffer SSE.
650
+ _attach_byok_client(task_id, body.provider_credentials)
651
+
652
  # BG-4: restore cross-session handoff context (async, non-blocking)
653
  if body.session_id:
654
  _hctx = await sb_restore_handoff_context(body.session_id)
 
757
  async def cancel_agent_task(task_id: str, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
758
  if task_id in _agent_tasks:
759
  _agent_tasks[task_id]['status'] = 'CANCELLED'
760
+ _task_ai_clients.pop(task_id, None)
761
  reg = _loop_registry.get(task_id)
762
  if reg and not reg.get('done'):
763
  at = reg.get('asyncio_task')
 
970
  async def run_loop() -> None:
971
  try:
972
  from agents.unified_loop import UnifiedAgentLoop
973
+ # Ogni task BYOK usa il suo client effimero; gli altri mantengono
974
+ # il singleton runtime. Le credenziali non entrano nel task dict.
975
+ client = _task_ai_clients.get(task_id) or _get_ai_client()
976
  try:
977
  from agents.critic import Critic
978
  from agents.response_verifier import ResponseVerifier
 
1075
  _hctx = task.get("_handoff_context", "")
1076
  if _hctx:
1077
  context_str = f"{_hctx}\n\n{context_str}".strip()
1078
+ # I router persona dipendono dalle env del backend: con BYOK il
1079
+ # client per task resta autorevole in ogni fase, incluso il planner.
1080
+ _is_byok_task = task_id in _task_ai_clients
1081
+ _persona_client = client if _is_byok_task else _get_persona_llm_client(_persona, client)
1082
+ _planner = _get_planner()
1083
+ if _is_byok_task:
1084
+ try:
1085
+ from agents.planner import Planner
1086
+ _planner = Planner(llm_client=client)
1087
+ except Exception as exc:
1088
+ _logger.warning("[agent] BYOK planner fallback: %s", type(exc).__name__)
1089
  loop = UnifiedAgentLoop(
1090
  llm_client=_persona_client, critic=_critic, verifier=_verifier,
1091
+ memory=await _get_mem_manager_async(), executor=_get_executor(), planner=_planner,
1092
  )
1093
  step_idx = [0]
1094
  _backend_steps: list[dict] = [] # GAP-SYNC-FIX: log step per resume preciso
 
1380
  asyncio.create_task(_tg_error(task_id, task.get('goal', ''), str(err))).add_done_callback(_log_task_exc)
1381
 
1382
  finally:
1383
+ # Il task è terminale: rimuove l’unico riferimento alle credenziali
1384
+ # BYOK, lasciando replay SSE e metadati senza segreti.
1385
+ _task_ai_clients.pop(task_id, None)
1386
  reg_entry['done'] = True
1387
  reg_entry['finished_at'] = time.time()
1388
  for q in list(reg_entry['subscriber_queues']):
api/state.py CHANGED
@@ -7,7 +7,7 @@ import os, time, asyncio as _asyncio_mod, json as _json, re as _re
7
  import logging
8
  from typing import Optional, Any, AsyncIterator, List, Tuple
9
  from fastapi import HTTPException, APIRouter, Request, Body
10
- from pydantic import BaseModel, field_validator
11
  from .version import RUNTIME_VERSION
12
 
13
  _logger = logging.getLogger("api.state")
@@ -128,6 +128,9 @@ SENSITIVE = {
128
  # ── In-memory stores ──────────────────────────────────────────────────────────
129
  _mem_fallback: dict[str, dict] = {}
130
  _agent_tasks: dict[str, dict] = {}
 
 
 
131
  _run_stream_tasks: dict[str, dict] = {}
132
  _loop_registry: dict[str, dict] = {}
133
  _LOOP_REGISTRY_TTL_S: float = 10 * 60
@@ -245,10 +248,13 @@ def _prune_agent_tasks() -> None:
245
  and now - v.get('created_at', 0) > _AGENT_TASK_TTL_MS]
246
  for k in expired:
247
  _agent_tasks.pop(k, None)
 
 
248
  if len(_agent_tasks) > _AGENT_TASK_MAX:
249
  oldest = sorted(_agent_tasks.items(), key=lambda x: x[1].get('created_at', 0))
250
  for k, _ in oldest[:len(_agent_tasks) - _AGENT_TASK_MAX]:
251
  _agent_tasks.pop(k, None)
 
252
 
253
  def _prune_loop_registry() -> None:
254
  now = time.time()
@@ -284,6 +290,30 @@ class ReasonLoopIn(BaseModel):
284
  def coerce_str(cls, v: object) -> str:
285
  return str(v).strip()[:2000] if v else ""
286
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
287
  class AgentTaskIn(BaseModel):
288
  goal: str
289
  context: list[dict] = []
@@ -295,6 +325,7 @@ class AgentTaskIn(BaseModel):
295
  resume_from_step: Optional[int] = None
296
  persona: Optional[str] = None
297
  negative_constraints: Optional[str] = ""
 
298
 
299
  @field_validator('goal', mode='before')
300
  @classmethod
 
7
  import logging
8
  from typing import Optional, Any, AsyncIterator, List, Tuple
9
  from fastapi import HTTPException, APIRouter, Request, Body
10
+ from pydantic import BaseModel, Field, SecretStr, field_validator
11
  from .version import RUNTIME_VERSION
12
 
13
  _logger = logging.getLogger("api.state")
 
128
  # ── In-memory stores ──────────────────────────────────────────────────────────
129
  _mem_fallback: dict[str, dict] = {}
130
  _agent_tasks: dict[str, dict] = {}
131
+ # Client LLM BYOK associati al task: memoria effimera, mai serializzata nei task,
132
+ # checkpoint, eventi SSE o persistenza Supabase.
133
+ _task_ai_clients: dict[str, Any] = {}
134
  _run_stream_tasks: dict[str, dict] = {}
135
  _loop_registry: dict[str, dict] = {}
136
  _LOOP_REGISTRY_TTL_S: float = 10 * 60
 
248
  and now - v.get('created_at', 0) > _AGENT_TASK_TTL_MS]
249
  for k in expired:
250
  _agent_tasks.pop(k, None)
251
+ _task_ai_clients.pop(k, None)
252
+
253
  if len(_agent_tasks) > _AGENT_TASK_MAX:
254
  oldest = sorted(_agent_tasks.items(), key=lambda x: x[1].get('created_at', 0))
255
  for k, _ in oldest[:len(_agent_tasks) - _AGENT_TASK_MAX]:
256
  _agent_tasks.pop(k, None)
257
+ _task_ai_clients.pop(k, None)
258
 
259
  def _prune_loop_registry() -> None:
260
  now = time.time()
 
290
  def coerce_str(cls, v: object) -> str:
291
  return str(v).strip()[:2000] if v else ""
292
 
293
+ class ProviderCredentialsIn(BaseModel):
294
+ """Credenziali LLM BYOK limitate ai provider OpenAI-compatible supportati.
295
+
296
+ SecretStr evita l’esposizione accidentale del valore in repr o log Pydantic;
297
+ as_runtime_config() è l’unico punto che restituisce stringhe al client effimero.
298
+ """
299
+ groq: list[SecretStr] = Field(default_factory=list)
300
+ openrouter: list[SecretStr] = Field(default_factory=list)
301
+
302
+ @field_validator('groq', 'openrouter', mode='before')
303
+ @classmethod
304
+ def coerce_tokens(cls, value: object) -> list[object]:
305
+ return value if isinstance(value, list) else []
306
+
307
+ def as_runtime_config(self) -> dict[str, list[str]]:
308
+ credentials: dict[str, list[str]] = {}
309
+ for provider in ('groq', 'openrouter'):
310
+ values = [item.get_secret_value().strip() for item in getattr(self, provider)]
311
+ values = list(dict.fromkeys(value for value in values if value))
312
+ if values:
313
+ credentials[provider] = values
314
+ return credentials
315
+
316
+
317
  class AgentTaskIn(BaseModel):
318
  goal: str
319
  context: list[dict] = []
 
325
  resume_from_step: Optional[int] = None
326
  persona: Optional[str] = None
327
  negative_constraints: Optional[str] = ""
328
+ provider_credentials: Optional[ProviderCredentialsIn] = None
329
 
330
  @field_validator('goal', mode='before')
331
  @classmethod
models/ai_client.py CHANGED
@@ -74,8 +74,13 @@ _PROVIDER_DEFS = [
74
 
75
 
76
  class AIClient:
77
- def __init__(self) -> None:
78
- self.providers = self._load_providers()
 
 
 
 
 
79
  self._client_cache: dict[tuple[str, str, str], OpenAI] = {}
80
  # Round-robin e circuit breaker sono indicizzati per purpose e profilo.
81
  self._rr_indices: dict[str, int] = {}
@@ -83,6 +88,39 @@ class AIClient:
83
  self._breaker_threshold = 2
84
  self._breaker_cooldown_s = 60.0
85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  def _load_providers(self) -> list[ProviderConfig]:
87
  """Carica la flotta: prova Supabase (tabella `ai_providers`, source of
88
  truth dichiarata in supabase/migrations/20260711_ai_providers_fleet.sql),
@@ -375,7 +413,7 @@ class AIClient:
375
  return provider
376
 
377
  async def chat(self, messages: list, model: Optional[str] = None, temperature: float = 0.7, max_tokens: int = 4096) -> str:
378
- if not self.providers: self.providers = self._load_providers()
379
 
380
  # S-CACHE-1: Lookup semantico preventivo
381
  cached = await get_cached_response(messages)
@@ -464,7 +502,7 @@ class AIClient:
464
 
465
  async def stream_chat(self, messages: list, model: Optional[str] = None, temperature: float = 0.7, max_tokens: int = 4096) -> AsyncIterator[str]:
466
  """Streaming con failover immediato tra i provider configurati."""
467
- if not self.providers: self.providers = self._load_providers()
468
  # Nessun provider configurato: feedback immediato all'utente invece di
469
  # cadere silenziosamente nel loop vuoto e dare un messaggio generico.
470
  if not self.providers:
 
74
 
75
 
76
  class AIClient:
77
+ def __init__(self, byok_credentials: dict[str, list[str]] | None = None) -> None:
78
+ # Le chiavi BYOK appartengono a un singolo task e vivono solo in questa
79
+ # istanza: non vengono scritte in env, Supabase, cache semantica o log.
80
+ self._byok_providers = self._providers_from_byok(byok_credentials or {})
81
+ # I profili BYOK precedono i provider runtime: la stessa API conserva
82
+ # comunque il fallback server-side in caso di quota o errore upstream.
83
+ self.providers = self._byok_providers + self._load_providers()
84
  self._client_cache: dict[tuple[str, str, str], OpenAI] = {}
85
  # Round-robin e circuit breaker sono indicizzati per purpose e profilo.
86
  self._rr_indices: dict[str, int] = {}
 
88
  self._breaker_threshold = 2
89
  self._breaker_cooldown_s = 60.0
90
 
91
+ @staticmethod
92
+ def _providers_from_byok(credentials: dict[str, list[str]]) -> list[ProviderConfig]:
93
+ """Build task-scoped provider profiles from browser BYOK credentials.
94
+
95
+ Only known OpenAI-compatible providers are accepted. Values are copied
96
+ into the transient client instance and intentionally never logged or
97
+ persisted; empty, malformed and unsupported entries are ignored.
98
+ """
99
+ providers: list[ProviderConfig] = []
100
+ for definition in _PROVIDER_DEFS:
101
+ raw_tokens = credentials.get(definition["name"], [])
102
+ if not isinstance(raw_tokens, list):
103
+ continue
104
+ seen_tokens: set[str] = set()
105
+ for raw_token in raw_tokens:
106
+ if not isinstance(raw_token, str):
107
+ continue
108
+ token = raw_token.strip()
109
+ if not token or token in seen_tokens:
110
+ continue
111
+ seen_tokens.add(token)
112
+ providers.append(ProviderConfig(
113
+ id=-(10_000 + len(providers)),
114
+ name=definition["name"],
115
+ api_key=token,
116
+ base_url=definition["base_url"],
117
+ default_model=os.getenv(definition["model_env"], definition["default_model"]),
118
+ tier=definition["tier"],
119
+ purpose=definition["purpose"],
120
+ profile=f"byok-{len(seen_tokens)}",
121
+ ))
122
+ return providers
123
+
124
  def _load_providers(self) -> list[ProviderConfig]:
125
  """Carica la flotta: prova Supabase (tabella `ai_providers`, source of
126
  truth dichiarata in supabase/migrations/20260711_ai_providers_fleet.sql),
 
413
  return provider
414
 
415
  async def chat(self, messages: list, model: Optional[str] = None, temperature: float = 0.7, max_tokens: int = 4096) -> str:
416
+ if not self.providers: self.providers = self._byok_providers + self._load_providers()
417
 
418
  # S-CACHE-1: Lookup semantico preventivo
419
  cached = await get_cached_response(messages)
 
502
 
503
  async def stream_chat(self, messages: list, model: Optional[str] = None, temperature: float = 0.7, max_tokens: int = 4096) -> AsyncIterator[str]:
504
  """Streaming con failover immediato tra i provider configurati."""
505
+ if not self.providers: self.providers = self._byok_providers + self._load_providers()
506
  # Nessun provider configurato: feedback immediato all'utente invece di
507
  # cadere silenziosamente nel loop vuoto e dare un messaggio generico.
508
  if not self.providers:
tests/test_ai_client_byok.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ import types
3
+ import unittest
4
+ from unittest.mock import patch
5
+
6
+
7
+ _semantic_cache = types.ModuleType("api.semantic_cache")
8
+
9
+
10
+ async def _cache_miss(*_args, **_kwargs):
11
+ return None
12
+
13
+
14
+ async def _cache_noop(*_args, **_kwargs):
15
+ return None
16
+
17
+
18
+ _semantic_cache.get_cached_response = _cache_miss
19
+ _semantic_cache.set_cached_response = _cache_noop
20
+ sys.modules.setdefault("api.semantic_cache", _semantic_cache)
21
+
22
+ from models.ai_client import AIClient
23
+
24
+
25
+ class AIClientByokTests(unittest.TestCase):
26
+ def test_byok_profiles_are_preferred_and_do_not_require_runtime_environment(self):
27
+ with patch.object(AIClient, "_load_providers", return_value=[]):
28
+ client = AIClient(byok_credentials={
29
+ "groq": [" gsk-primary ", "gsk-primary", "gsk-fallback"],
30
+ "openrouter": ["sk-or-primary"],
31
+ })
32
+
33
+ self.assertEqual([provider.name for provider in client.providers], [
34
+ "groq", "groq", "openrouter",
35
+ ])
36
+ self.assertEqual([provider.profile for provider in client.providers], [
37
+ "byok-1", "byok-2", "byok-1",
38
+ ])
39
+ self.assertEqual([provider.api_key for provider in client.providers], [
40
+ "gsk-primary", "gsk-fallback", "sk-or-primary",
41
+ ])
42
+ self.assertTrue(all(provider.id < 0 for provider in client.providers))
43
+
44
+ def test_unknown_or_malformed_credentials_are_ignored(self):
45
+ with patch.object(AIClient, "_load_providers", return_value=[]):
46
+ client = AIClient(byok_credentials={
47
+ "unsupported": ["secret"],
48
+ "groq": "not-a-list", # type: ignore[dict-item]
49
+ "openrouter": ["", " "],
50
+ })
51
+
52
+ self.assertEqual(client.providers, [])
53
+
54
+
55
+ if __name__ == "__main__":
56
+ unittest.main()