from __future__ import annotations from dataclasses import dataclass from typing import Any import openai from openai import OpenAI from tenacity import ( retry, retry_if_exception_type, stop_after_attempt, wait_exponential, ) from ..genome.hypothesis import HypothesisAgentGenome, ModelTier # Modelli configurati per Phase 1 — tutti via OpenRouter MODEL_TIER_S = "google/gemini-3-flash-preview" MODEL_TIER_A = "deepseek/deepseek-v4-flash" MODEL_TIER_B = "deepseek/deepseek-v4-flash" MODEL_TIER_C = "qwen/qwen-2.5-72b-instruct" MODEL_TIER_D = "openai/gpt-oss-20b" OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" class EmptyCompletionError(RuntimeError): pass # Errori transient: retry. Auth/InvalidRequest: NO retry. # RateLimitError (HTTP 429) ora retryable: provider OpenRouter come DeepInfra # applicano rate limit upstream temporaneo, recuperabile con backoff. _RETRYABLE_EXCEPTIONS: tuple[type[BaseException], ...] = ( openai.APIConnectionError, openai.APITimeoutError, openai.InternalServerError, openai.RateLimitError, EmptyCompletionError, ) @dataclass(frozen=True) class CompletionResult: text: str input_tokens: int output_tokens: int tier: ModelTier model: str class LLMClient: # Provider OpenRouter da escludere di default. Novita rifiuta /completions # endpoint per modelli Qwen 2.x — vedi bug 2026-05-12. DEFAULT_PROVIDER_IGNORE: tuple[str, ...] = ("Novita",) def __init__( self, openrouter_api_key: str, model_tier_s: str = MODEL_TIER_S, model_tier_a: str = MODEL_TIER_A, model_tier_b: str = MODEL_TIER_B, model_tier_c: str = MODEL_TIER_C, model_tier_d: str = MODEL_TIER_D, openrouter_base_url: str = OPENROUTER_BASE_URL, provider_ignore: tuple[str, ...] | None = None, ) -> None: self.model_tier_s = model_tier_s self.model_tier_a = model_tier_a self.model_tier_b = model_tier_b self.model_tier_c = model_tier_c self.model_tier_d = model_tier_d self.openrouter_base_url = openrouter_base_url self._provider_ignore = ( provider_ignore if provider_ignore is not None else self.DEFAULT_PROVIDER_IGNORE ) self._tier_models: dict[ModelTier, str] = { ModelTier.S: model_tier_s, ModelTier.A: model_tier_a, ModelTier.B: model_tier_b, ModelTier.C: model_tier_c, ModelTier.D: model_tier_d, } self._client = OpenAI(api_key=openrouter_api_key, base_url=openrouter_base_url) @retry( stop=stop_after_attempt(5), wait=wait_exponential(multiplier=2.0, min=2.0, max=30.0), retry=retry_if_exception_type(_RETRYABLE_EXCEPTIONS), reraise=True, ) def complete( self, genome: HypothesisAgentGenome, system: str, user: str, max_tokens: int = 2000, ) -> CompletionResult: model = self._tier_models[genome.model_tier] extra_body: dict[str, Any] = {} if self._provider_ignore: extra_body["provider"] = {"ignore": list(self._provider_ignore)} resp = self._client.chat.completions.create( model=model, messages=[ {"role": "system", "content": system}, {"role": "user", "content": user}, ], temperature=genome.temperature, top_p=genome.top_p, max_tokens=max_tokens, extra_body=extra_body or None, ) if not resp.choices or resp.choices[0].message.content is None: raise EmptyCompletionError(f"empty response from {model}") usage = resp.usage return CompletionResult( text=resp.choices[0].message.content, input_tokens=usage.prompt_tokens if usage else 0, output_tokens=usage.completion_tokens if usage else 0, tier=genome.model_tier, model=model, )