Módulo 6: Code Quality Patterns para AI

4. Config Management

Descripción

La configuración de una AI app es más compleja que la de una app web tradicional: temperatura, modelo, max_tokens, retry parameters, system prompts, budget limits, y API keys. Con pydantic-settings, toda esa configuración vive en un solo lugar, con tipos, validación, y lectura automática desde variables de entorno. Esta cápsula implementa un sistema de configuración completo.


El problema de la configuración dispersa

# Antes: config dispersa en múltiples lugares
# models.py
MODEL = "gpt-4o-mini"
TEMPERATURE = 0.7

# utils.py
MAX_TOKENS = int(os.getenv("MAX_TOKENS", "500"))
RETRY_ATTEMPTS = 3

# guardrails/pipeline.py
MAX_INPUT_TOKENS = 4000
INJECTION_THRESHOLD = 0.8

# main.py
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
    raise ValueError("OPENAI_API_KEY not set")

# Para cambiar MAX_TOKENS en staging:
# → Buscar en los 4 archivos
# → No hay validación de tipo
# → Si escribes MAKS_TOKENS en .env, no hay error — silenciosamente usa el default

# Después: todo en un Settings centralizado
# settings = get_settings()
# settings.model → "gpt-4o-mini"
# settings.temperature → 0.7
# settings.max_tokens → 500
# Si MAKS_TOKENS está en .env, pydantic lo ignora (no está en el modelo)
# Si MAX_TOKENS="abc", pydantic lanza ValidationError al inicio

pydantic-settings: configuración type-safe

pip install pydantic-settings python-dotenv
# src/config.py
from pydantic import Field, SecretStr, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import Literal, Optional
from functools import lru_cache

class Settings(BaseSettings):
    """
    Configuración centralizada de la AI app.
    
    Lee automáticamente desde:
    1. Variables de entorno (highest priority)
    2. .env.{ENVIRONMENT} si existe
    3. .env (fallback)
    4. Defaults en el modelo (lowest priority)
    
    Ejemplo de .env:
        OPENAI_API_KEY=sk-...
        MODEL=gpt-4o-mini
        TEMPERATURE=0.0
        MAX_TOKENS=500
    """
    
    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        case_sensitive=False,    # MODEL == model == Model
        extra="ignore"           # Ignorar variables no definidas (no crash)
    )
    
    # ─── App Settings ────────────────────────────────────────────
    environment: Literal["development", "staging", "production", "testing"] = "development"
    app_name: str = "sentiment-analyzer"
    app_version: str = "0.1.0"
    debug: bool = False
    
    # ─── OpenAI Settings ─────────────────────────────────────────
    # SecretStr: no aparece en repr, logs, ni str()
    openai_api_key: SecretStr = Field(default=SecretStr(""))
    
    # ─── LLM Parameters ──────────────────────────────────────────
    model: str = "gpt-4o-mini"
    temperature: float = 0.0
    max_tokens: int = 500
    seed: Optional[int] = None
    
    # ─── Retry Settings ──────────────────────────────────────────
    retry_attempts: int = 3
    retry_delay_seconds: float = 1.0
    retry_backoff_multiplier: float = 2.0
    
    # ─── Guardrail Settings ───────────────────────────────────────
    max_input_chars: int = 20_000       # Límite de caracteres de input
    max_input_tokens: int = 4_000       # Límite de tokens (con tiktoken)
    enable_injection_check: bool = True
    enable_pii_redaction: bool = True
    enable_content_filter: bool = True
    
    # ─── Logging Settings ─────────────────────────────────────────
    log_level: str = "INFO"
    log_to_file: bool = False
    log_file_path: str = "logs/app.json"
    
    # ─── Cost Settings ────────────────────────────────────────────
    max_cost_per_request_usd: float = 0.10
    daily_budget_usd: float = 10.0
    
    # ─── Mock Settings ────────────────────────────────────────────
    use_mock_llm: bool = False
    mock_response: str = '{"sentiment": "positive", "score": 0.8, "confidence": 0.9}'

Validators: validación declarativa

# src/config.py (continuación)

class Settings(BaseSettings):
    # ... (campos del bloque anterior) ...
    
    @field_validator("temperature")
    @classmethod
    def validate_temperature(cls, v: float) -> float:
        if not 0.0 <= v <= 2.0:
            raise ValueError(f"temperature debe estar en [0.0, 2.0], got {v}")
        return v
    
    @field_validator("max_tokens")
    @classmethod
    def validate_max_tokens(cls, v: int) -> int:
        if not 1 <= v <= 128_000:
            raise ValueError(f"max_tokens debe estar en [1, 128000], got {v}")
        return v
    
    @field_validator("log_level")
    @classmethod
    def validate_log_level(cls, v: str) -> str:
        allowed = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
        v_upper = v.upper()
        if v_upper not in allowed:
            raise ValueError(f"log_level debe ser uno de {allowed}")
        return v_upper
    
    @field_validator("model")
    @classmethod
    def validate_model(cls, v: str) -> str:
        # No validar contra lista estricta — OpenAI añade modelos frecuentemente
        # Solo validar que no está vacío
        if not v.strip():
            raise ValueError("model no puede estar vacío")
        return v.strip()
    
    @model_validator(mode="after")
    def validate_production_requirements(self) -> "Settings":
        """Validaciones que dependen de múltiples campos."""
        if self.environment == "production":
            if self.use_mock_llm:
                raise ValueError(
                    "use_mock_llm=True no está permitido en production. "
                    "Establecer USE_MOCK_LLM=false"
                )
            if not self.openai_api_key.get_secret_value():
                raise ValueError(
                    "openai_api_key es requerido en production. "
                    "Establecer OPENAI_API_KEY=sk-..."
                )
            if self.log_level == "DEBUG":
                raise ValueError(
                    "log_level=DEBUG no está permitido en production "
                    "(puede exponer información sensible)"
                )
        return self
    
    # ─── Properties ───────────────────────────────────────────────
    
    @property
    def is_production(self) -> bool:
        return self.environment == "production"
    
    @property
    def is_development(self) -> bool:
        return self.environment == "development"
    
    @property
    def is_testing(self) -> bool:
        return self.environment == "testing"
    
    def create_openai_client(self):
        """Factory para crear el cliente OpenAI con la API key configurada."""
        from openai import OpenAI
        api_key = self.openai_api_key.get_secret_value()
        if not api_key:
            raise ValueError("OPENAI_API_KEY no configurada")
        return OpenAI(api_key=api_key)
    
    def get_llm_params(self) -> dict:
        """Parámetros para la llamada al LLM como dict."""
        params = {
            "model": self.model,
            "temperature": self.temperature,
            "max_tokens": self.max_tokens,
        }
        if self.seed is not None:
            params["seed"] = self.seed
        return params


@lru_cache()
def get_settings() -> Settings:
    """
    Carga y cachea la configuración.
    
    Por qué lru_cache():
    - pydantic-settings lee .env en cada instanciación (I/O)
    - En producción, la config no cambia entre requests
    - lru_cache() lee el .env una sola vez al inicio
    
    Para tests: usar override_settings() fixture
    """
    return Settings()

Cargar por entorno con archivos .env múltiples

# Patrón: .env como base, .env.{environment} como override

import os
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict

def _get_env_files() -> list[str]:
    """
    Retorna los archivos .env a cargar en orden de prioridad (último = más alto).
    
    Orden:
    1. .env (base, menor prioridad)
    2. .env.{environment} (override específico del entorno)
    3. Variables de entorno del sistema (mayor prioridad, siempre)
    """
    env = os.getenv("ENVIRONMENT", "development")
    files = [".env"]
    
    env_specific = f".env.{env}"
    if Path(env_specific).exists():
        files.append(env_specific)
    
    return files

class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=_get_env_files(),
        env_file_encoding="utf-8",
        extra="ignore"
    )
    
    environment: str = "development"
    # ... resto de campos ...

# Estructura de archivos:
# .env              → Variables comunes (sin secrets)
# .env.development  → Overrides de dev (use_mock_llm=true, debug=true)
# .env.staging      → Overrides de staging (budget caps, real LLM)
# .env.production   → Overrides de prod (secrets en Vault o env vars, no aquí)
# .env.testing      → Overrides para tests (mock=true, log_level=WARNING)
# .env.local        → Override local personal (en .gitignore)

Ejemplo de archivos .env

# .env (base — commiteado, sin secrets)
APP_NAME=sentiment-analyzer
APP_VERSION=0.1.0
MODEL=gpt-4o-mini
MAX_TOKENS=500
RETRY_ATTEMPTS=3
LOG_LEVEL=INFO
# .env.development (overrides para dev)
ENVIRONMENT=development
TEMPERATURE=0.0
DEBUG=true
USE_MOCK_LLM=true
LOG_LEVEL=DEBUG
DAILY_BUDGET_USD=1.0
# .env.staging (overrides para staging)
ENVIRONMENT=staging
USE_MOCK_LLM=false
LOG_LEVEL=INFO
DAILY_BUDGET_USD=10.0
ENABLE_INJECTION_CHECK=true
# .env.example (documentación — commiteado, sin valores reales)
# Copia esto a .env y llena los valores

# REQUERIDO en producción:
OPENAI_API_KEY=

# Opcional (tiene defaults razonables):
ENVIRONMENT=development
MODEL=gpt-4o-mini
TEMPERATURE=0.0
MAX_TOKENS=500
LOG_LEVEL=INFO
USE_MOCK_LLM=false
DAILY_BUDGET_USD=10.0

Usar SecretStr para secrets

# ¿Por qué SecretStr en vez de str para api_key?

from pydantic import SecretStr

# Con str:
settings = Settings(openai_api_key="sk-abcdef123456")
print(settings)  # openai_api_key='sk-abcdef123456' ← EN LOGS!
print(repr(settings.openai_api_key))  # 'sk-abcdef123456' ← EXPUESTA
log.info("settings loaded", settings=settings.model_dump())  # ← API KEY EN LOGS!

# Con SecretStr:
settings = Settings(openai_api_key=SecretStr("sk-abcdef123456"))
print(settings)  # openai_api_key=SecretStr('**********') ← ENMASCARADA
print(repr(settings.openai_api_key))  # SecretStr('**********') ← ENMASCARADA

# Para usar el valor:
api_key = settings.openai_api_key.get_secret_value()  # Explícito
client = OpenAI(api_key=api_key)

# ❌ Esto sí expone el valor:
log.info("api_key", key=settings.openai_api_key.get_secret_value())
# ✅ Solo extraer donde se necesita, no loguear

Testing con configuración inyectada

# tests/conftest.py

import pytest
from unittest.mock import patch
from src.config import Settings, get_settings

@pytest.fixture
def test_settings() -> Settings:
    """Settings para tests: siempre mock, nunca llama al LLM real."""
    return Settings(
        environment="testing",
        openai_api_key="sk-test-key-not-real",
        use_mock_llm=True,
        log_level="WARNING",  # Menos ruido en tests
        model="gpt-4o-mini",
        temperature=0.0,
        max_tokens=500
    )

@pytest.fixture
def override_settings(test_settings: Settings):
    """
    Override de get_settings() para todos los tests.
    Evita leer el .env real durante los tests.
    """
    with patch("src.config.get_settings", return_value=test_settings):
        # Limpiar el cache de lru_cache
        get_settings.cache_clear()
        yield test_settings
    get_settings.cache_clear()

# Uso en tests:
def test_something_with_config(override_settings):
    settings = get_settings()  # Retorna test_settings, no lee .env
    assert settings.use_mock_llm is True
    assert settings.environment == "testing"

# Alternativa: override inline con monkeypatch
def test_with_monkeypatch(monkeypatch):
    monkeypatch.setenv("MODEL", "gpt-4o")
    monkeypatch.setenv("TEMPERATURE", "0.5")
    get_settings.cache_clear()
    settings = Settings()  # Lee los env vars del monkeypatch
    assert settings.model == "gpt-4o"
    assert settings.temperature == 0.5

Ejercicios

Ejercicio 1: Agregar campos a Settings

Añade los siguientes campos a tu Settings con valores por defecto razonables:

  1. max_input_length: int — máximo de caracteres del input del usuario
  2. enable_guardrails: bool — activar o desactivar los guardrails
  3. fallback_model: str — modelo alternativo si el principal falla
Ver solución
class Settings(BaseSettings):
    # ...
    max_input_length: int = 10_000      # 10K chars es razonable para análisis
    enable_guardrails: bool = True      # Siempre activos por defecto
    fallback_model: str = "gpt-4o-mini" # Mismo modelo como fallback (más barato)
    
    @field_validator("max_input_length")
    @classmethod
    def validate_max_input(cls, v: int) -> int:
        if v < 10:
            raise ValueError("max_input_length debe ser >= 10")
        if v > 100_000:
            raise ValueError("max_input_length > 100K puede ser muy caro")
        return v

Ejercicio 2: Detectar errores de configuración al startup

¿Qué problema hay con este código?

@app.post("/analyze")
async def analyze(body: AnalyzeRequest):
    api_key = os.getenv("OPENAI_API_KEY")  # Lee en cada request
    if not api_key:
        raise HTTPException(500, "API key not configured")
    client = OpenAI(api_key=api_key)
    ...
Ver solución

Problema: El error se detecta en el primer request, no al inicio de la app. Si OPENAI_API_KEY no está configurada, la app arranca sin error y falla solo cuando un usuario hace un request.

Solución con pydantic-settings: el model_validator con mode="after" verifica al inicio que la API key está presente en producción. Si falta, el servidor no arranca (ValidationError antes de que FastAPI empiece a recibir requests).

# Con Settings, el error es al startup:
settings = get_settings()  # ← ValidationError aquí si api_key falta en prod
# La app nunca arranca → el error es visible inmediatamente

"Fail fast": mejor crashear al inicio con un error claro que servir requests que van a fallar.


Ejercicio 3: Configuración por entorno

Escribe los archivos .env.development y .env.production para una app donde:

  • En dev: usar mock LLM, DEBUG logging, budget de $1/día
  • En prod: LLM real, INFO logging, budget de $50/día
Ver solución
# .env.development
ENVIRONMENT=development
USE_MOCK_LLM=true
LOG_LEVEL=DEBUG
DAILY_BUDGET_USD=1.0
TEMPERATURE=0.0
ENABLE_INJECTION_CHECK=false  # Menos restrictivo en dev para facilitar debugging
# .env.production
ENVIRONMENT=production
USE_MOCK_LLM=false
LOG_LEVEL=INFO
DAILY_BUDGET_USD=50.0
TEMPERATURE=0.0
ENABLE_INJECTION_CHECK=true
ENABLE_PII_REDACTION=true
ENABLE_CONTENT_FILTER=true
# OPENAI_API_KEY se setea como variable de entorno en el servidor, NO en .env

Resumen

  • pydantic-settings centraliza toda la configuración en un modelo Python con tipos, defaults, y validación
  • SecretStr para secrets: no aparecen en repr, logs, ni dumps automáticos
  • model_validator al startup: detecta configuración incorrecta antes de recibir el primer request
  • Múltiples .env files: .env como base + .env.{environment} como override
  • lru_cache() en get_settings(): la config se lee una sola vez al inicio, no en cada request
  • Tests: fixture override_settings para inyectar config de test sin leer el .env real

Recursos adicionales

  1. pydantic-settings Documentation — Documentación oficial completa
  2. SecretStr in Pydantic — Tipos para secrets
  3. 12-Factor App — Config — La filosofía detrás del config management
  4. python-dotenv — Para cargar .env en Python
  5. Pydantic Validators — field_validator y model_validator