""" GraphoLab — Provider abstraction layer. Centralises detection of LLM/VLM/Embedding provider (Ollama vs OpenAI) and exposes a ready-made OpenAI client. All core modules import from here instead of hard-coding Ollama assumptions. Per-request API key propagation ──────────────────────────────── FastAPI routers set `set_request_api_key(key)` at the start of each request via a dependency. `get_openai_client()` picks it up automatically through a `contextvars.ContextVar`, so core modules need no signature changes. Priority order: 1. Per-request key (from user's UserSettings row, decrypted) 2. Global key from `OPENAI_API_KEY` env var (Docker / host env — never written to file) """ from __future__ import annotations import os from contextvars import ContextVar from typing import Optional # ── Model catalogues ────────────────────────────────────────────────────────── # Model ID prefixes that unambiguously identify an OpenAI model OPENAI_MODEL_PREFIXES: tuple[str, ...] = ("gpt-", "text-embedding-3-", "o1-", "o3-") # Latest models as of April 2026 OPENAI_LLM_MODELS: list[str] = ["gpt-5.4", "gpt-5.4-mini", "gpt-5.4-nano"] OPENAI_VLM_MODELS: list[str] = ["gpt-5.4", "gpt-5.4-mini"] # all gpt-5.4 have native vision OPENAI_EMBED_MODELS: list[str] = ["text-embedding-3-small", "text-embedding-3-large"] # Embedding output dimensions EMBED_DIMS: dict[str, int] = { "text-embedding-3-small": 1536, "text-embedding-3-large": 3072, "nomic-embed-text": 768, } # ── Provider detection ──────────────────────────────────────────────────────── def is_openai_model(model_name: str) -> bool: """Return True if *model_name* refers to an OpenAI model.""" return any(model_name.startswith(p) for p in OPENAI_MODEL_PREFIXES) def embed_dim_for(model: str) -> int: """Return the embedding vector dimension for *model*.""" return EMBED_DIMS.get(model, 768) # ── Per-request API key (ContextVar) ───────────────────────────────────────── _request_api_key: ContextVar[str | None] = ContextVar("_request_api_key", default=None) def set_request_api_key(key: str | None) -> None: """Set the OpenAI API key for the current async request context.""" _request_api_key.set(key) # ── API key management ──────────────────────────────────────────────────────── def _read_openai_key() -> str: """Return the global OpenAI API key from the environment only. The global key is set via the OPENAI_API_KEY environment variable (Docker, host shell, or CI). It is NEVER written to or read from a file — per-user keys are stored encrypted in the database. """ return os.environ.get("OPENAI_API_KEY", "").strip() def openai_key_configured() -> bool: """Return True if a global OpenAI API key is available in the environment.""" return bool(_read_openai_key()) # ── Encryption helpers (Fernet) ─────────────────────────────────────────────── def _get_fernet(): """Return a Fernet instance using SETTINGS_ENCRYPTION_KEY, or None if not configured.""" try: from backend.config import settings key = settings.settings_encryption_key.strip() except Exception: key = os.environ.get("SETTINGS_ENCRYPTION_KEY", "").strip() if not key: return None try: from cryptography.fernet import Fernet return Fernet(key.encode() if isinstance(key, str) else key) except Exception: return None def encrypt_key(plain: str) -> str: """Encrypt *plain* with Fernet. Returns plain-text if no encryption key is configured.""" f = _get_fernet() if f is None: return plain return f.encrypt(plain.encode()).decode() def decrypt_key(enc: str) -> str: """Decrypt *enc* with Fernet. Returns *enc* as-is if no encryption key is configured.""" f = _get_fernet() if f is None: return enc try: return f.decrypt(enc.encode()).decode() except Exception: # Fallback: may be a plain-text key stored before encryption was enabled return enc # ── OpenAI client ───────────────────────────────────────────────────────────── _openai_client: Optional[object] = None _openai_client_key: str = "" # key used to build the cached client def get_openai_client(): """ Return an ``openai.OpenAI`` client. Key resolution order: 1. Per-request ContextVar (user's decrypted key, set by FastAPI dependency) 2. Global ``OPENAI_API_KEY`` env var A new client is created whenever the resolved key changes so stale credentials are never reused. Raises ``RuntimeError`` if no API key is available. """ global _openai_client, _openai_client_key key = _request_api_key.get() or _read_openai_key() if not key: raise RuntimeError( "OPENAI_API_KEY non configurata. " "Inserisci la chiave nella sezione Configurazione." ) # If the per-request ContextVar is set we always create a fresh client # (different users have different keys; caching across requests is unsafe). if _request_api_key.get(): from openai import OpenAI return OpenAI(api_key=key) # Global key: cache to avoid re-creating on every call if _openai_client is None or _openai_client_key != key: from openai import OpenAI _openai_client = OpenAI(api_key=key) _openai_client_key = key return _openai_client def invalidate_openai_client() -> None: """Force re-creation of the cached global client (call after env key change).""" global _openai_client, _openai_client_key _openai_client = None _openai_client_key = "" def validate_openai_key(key: str) -> bool: """ Return True if *key* is a valid OpenAI API key. Makes a lightweight ``models.list()`` call to verify. Raises ``RuntimeError`` if the openai package is not installed. """ try: from openai import OpenAI, AuthenticationError, PermissionDeniedError except ImportError as e: raise RuntimeError( "Il pacchetto 'openai' non è installato. " "Esegui: pip install 'openai>=2.0.0'" ) from e try: client = OpenAI(api_key=key, timeout=10.0) client.models.list() return True except (AuthenticationError, PermissionDeniedError): return False except Exception: # Network errors, timeouts, etc. — don't block the user return True