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:
max_input_length: int— máximo de caracteres del input del usuarioenable_guardrails: bool— activar o desactivar los guardrailsfallback_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
SecretStrpara secrets: no aparecen en repr, logs, ni dumps automáticosmodel_validatoral startup: detecta configuración incorrecta antes de recibir el primer request- Múltiples
.envfiles:.envcomo base +.env.{environment}como override lru_cache()enget_settings(): la config se lee una sola vez al inicio, no en cada request- Tests: fixture
override_settingspara inyectar config de test sin leer el.envreal
Recursos adicionales
- pydantic-settings Documentation — Documentación oficial completa
- SecretStr in Pydantic — Tipos para secrets
- 12-Factor App — Config — La filosofía detrás del config management
- python-dotenv — Para cargar .env en Python
- Pydantic Validators — field_validator y model_validator