Módulo 6: Code Quality Patterns para AI
6. Environment Management
Descripción
Una AI app tiene comportamientos radicalmente distintos en dev, staging y producción. En dev, quieres mock LLM y debug logging para iterar rápido. En staging, quieres el LLM real pero con budget caps para no gastar de más. En producción, quieres máxima seguridad, guardrails completos, y observabilidad. Esta cápsula implementa un sistema de environment management que hace esa diferenciación automáticamente.
Los 3 entornos y sus diferencias
┌────────────────────────────────────────────────────────────────────────┐
│ COMPARACIÓN DE ENTORNOS │
├──────────────────┬──────────────────┬──────────────────┬───────────────┤
│ Feature │ Development │ Staging │ Production │
├──────────────────┼──────────────────┼──────────────────┼───────────────┤
│ LLM Provider │ Mock (sin API) │ Real (OpenAI) │ Real (OpenAI) │
│ API Key │ No requerida │ Sandbox key │ Prod key │
│ Log Level │ DEBUG │ INFO │ INFO │
│ Full Prompt Log │ Sí │ No │ No │
│ Guardrails │ Desactivados OK │ Completos │ Completos │
│ PII Redaction │ Opcional │ Activo │ Activo │
│ Budget/día │ $0 (mock) │ $5-10 │ $50-500 │
│ Retry attempts │ 1 (falla rápido) │ 3 │ 3 │
│ Mock LLM │ ✅ permitido │ ❌ no permitido │ ❌ prohibido │
│ Debug flag │ true │ false │ false │
│ .env en repo │ Parcialmente │ Solo no-secrets │ Nunca │
│ Fail on startup │ No │ No │ Sí (strict) │
└──────────────────┴──────────────────┴──────────────────┴───────────────┘
Settings con comportamiento por entorno
# src/config.py
from typing import Literal
from pydantic import Field, SecretStr, model_validator, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from functools import lru_cache
import os
from pathlib import Path
def _env_files() -> list[str]:
"""Determina qué archivos .env cargar."""
env = os.getenv("ENVIRONMENT", "development")
files = []
# Base común (menor prioridad)
if Path(".env").exists():
files.append(".env")
# Override por entorno (mayor prioridad)
env_specific = f".env.{env}"
if Path(env_specific).exists():
files.append(env_specific)
# Override local personal (máxima prioridad entre archivos)
if Path(".env.local").exists():
files.append(".env.local")
return files
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=_env_files(),
env_file_encoding="utf-8",
extra="ignore"
)
# ─── Entorno ──────────────────────────────────────────────────
environment: Literal["development", "staging", "production", "testing"] = "development"
# ─── LLM ─────────────────────────────────────────────────────
openai_api_key: SecretStr = Field(default=SecretStr(""))
model: str = "gpt-4o-mini"
temperature: float = 0.0
max_tokens: int = 500
seed: int = None
use_mock_llm: bool = False
mock_response: str = '{"sentiment": "positive", "score": 0.8, "confidence": 0.9}'
# ─── Retry ────────────────────────────────────────────────────
retry_attempts: int = 3
retry_delay_seconds: float = 1.0
# ─── Logging ─────────────────────────────────────────────────
log_level: str = "INFO"
log_full_prompt: bool = False # Solo en debug
log_to_file: bool = False
log_file_path: str = "logs/app.json"
# ─── Guardrails ───────────────────────────────────────────────
enable_guardrails: bool = True
enable_pii_redaction: bool = True
enable_injection_check: bool = True
enable_content_filter: bool = True
max_input_chars: int = 10_000
# ─── Budget ───────────────────────────────────────────────────
max_cost_per_request_usd: float = 0.10
daily_budget_usd: float = 10.0
# ─── Feature flags ────────────────────────────────────────────
debug: bool = False
# ─── Validators ──────────────────────────────────────────────
@model_validator(mode="after")
def apply_environment_defaults(self) -> "Settings":
"""
Aplica configuraciones por defecto específicas del entorno.
Se llama DESPUÉS de que todos los campos se han establecido.
Esto permite que la configuración explícita (env vars) tenga
prioridad sobre los defaults del entorno.
"""
if self.environment == "development":
# En dev: optimizar para velocidad de iteración
# (Solo si no se establecieron explícitamente)
if not self.use_mock_llm:
# No forzar mock, pero loguearlo si el dev lo configuró explícitamente
pass
return self
@model_validator(mode="after")
def validate_environment_constraints(self) -> "Settings":
"""Validaciones específicas por entorno — algunas son prohibiciones hard."""
if self.environment == "production":
# En producción, estas son hard constraints — no pueden ser overrideadas
if self.use_mock_llm:
raise ValueError(
"PRODUCCIÓN: use_mock_llm no está permitido. "
"Establecer USE_MOCK_LLM=false"
)
if not self.openai_api_key.get_secret_value():
raise ValueError(
"PRODUCCIÓN: openai_api_key es requerido. "
"Establecer OPENAI_API_KEY=sk-..."
)
if self.log_level == "DEBUG":
raise ValueError(
"PRODUCCIÓN: log_level=DEBUG puede exponer información sensible. "
"Usar INFO o WARNING"
)
if self.log_full_prompt:
raise ValueError(
"PRODUCCIÓN: log_full_prompt=True puede exponer PII. "
"Establecer LOG_FULL_PROMPT=false"
)
if self.environment == "staging":
if self.use_mock_llm:
raise ValueError(
"STAGING: use_mock_llm no está permitido. "
"El objetivo de staging es probar con el LLM real."
)
return self
# ─── Properties ───────────────────────────────────────────────
@property
def is_production(self) -> bool:
return self.environment == "production"
@property
def is_development(self) -> bool:
return self.environment in ("development", "testing")
@property
def effective_log_level(self) -> str:
"""Log level efectivo basado en entorno y debug flag."""
if self.debug and self.environment != "production":
return "DEBUG"
return self.log_level
def create_openai_client(self):
from openai import OpenAI
key = self.openai_api_key.get_secret_value()
if not key:
raise ValueError("OPENAI_API_KEY no configurada")
return OpenAI(api_key=key)
@lru_cache()
def get_settings() -> Settings:
return Settings()
Archivos .env por entorno
# .env (base — commiteado, sin secrets)
APP_NAME=sentiment-analyzer
APP_VERSION=0.1.0
MODEL=gpt-4o-mini
TEMPERATURE=0.0
MAX_TOKENS=500
RETRY_ATTEMPTS=3
LOG_LEVEL=INFO
ENABLE_GUARDRAILS=true
ENABLE_PII_REDACTION=true
ENABLE_INJECTION_CHECK=true
ENABLE_CONTENT_FILTER=true
# .env.development (commiteado — override para dev)
ENVIRONMENT=development
USE_MOCK_LLM=true
LOG_LEVEL=DEBUG
LOG_FULL_PROMPT=true
DEBUG=true
DAILY_BUDGET_USD=1.0
ENABLE_INJECTION_CHECK=false
# .env.staging (commiteado — override para staging)
ENVIRONMENT=staging
USE_MOCK_LLM=false
LOG_LEVEL=INFO
LOG_FULL_PROMPT=false
DEBUG=false
DAILY_BUDGET_USD=10.0
MAX_COST_PER_REQUEST_USD=0.05
# OPENAI_API_KEY → variable de entorno en el servidor de staging, NO en este archivo
# .env.production (en .gitignore O vacío — solo documenta las variables esperadas)
ENVIRONMENT=production
USE_MOCK_LLM=false
LOG_LEVEL=INFO
LOG_FULL_PROMPT=false
DEBUG=false
DAILY_BUDGET_USD=50.0
# OPENAI_API_KEY → secret manager (AWS Secrets Manager, Vault, etc.)
# .env.example (commiteado — documentación de todas las variables)
# Copia a .env y configura los valores
# ENTORNO (requerido)
ENVIRONMENT=development # development | staging | production | testing
# API (requerido en staging y producción)
OPENAI_API_KEY= # sk-...
# LLM (opcional, tiene defaults)
MODEL=gpt-4o-mini
TEMPERATURE=0.0
MAX_TOKENS=500
# LOGGING
LOG_LEVEL=INFO # DEBUG | INFO | WARNING | ERROR
LOG_FULL_PROMPT=false # Solo en development
# MOCK (solo en development/testing)
USE_MOCK_LLM=false
# BUDGET
DAILY_BUDGET_USD=10.0
MAX_COST_PER_REQUEST_USD=0.10
Startup checks: fail fast en producción
# src/startup.py
"""
Checks que se ejecutan al inicio de la app.
En producción, un check fallido debe impedir que la app arranque.
"""
import structlog
from src.config import get_settings
log = structlog.get_logger()
def run_startup_checks() -> None:
"""
Ejecutar todos los checks de startup.
Lanza SystemExit si algún check falla en producción.
"""
settings = get_settings()
checks_passed = True
log.info(
"startup_checks_starting",
environment=settings.environment,
model=settings.model,
use_mock=settings.use_mock_llm
)
# Check 1: API key (solo si no es mock)
if not settings.use_mock_llm:
if not settings.openai_api_key.get_secret_value():
log.error("startup_check_failed", check="openai_api_key",
reason="OPENAI_API_KEY not configured")
checks_passed = False
else:
log.info("startup_check_passed", check="openai_api_key")
# Check 2: Conectividad al LLM (solo en staging/producción)
if settings.environment in ("staging", "production") and not settings.use_mock_llm:
try:
client = settings.create_openai_client()
# Test call mínima
models = client.models.list()
log.info("startup_check_passed", check="openai_connectivity",
models_available=len(list(models)))
except Exception as e:
log.error("startup_check_failed", check="openai_connectivity",
error=str(e)[:100])
if settings.is_production:
checks_passed = False
else:
log.warning("startup_check_degraded",
check="openai_connectivity",
note="No bloquea staging pero reportar al equipo")
# Check 3: Directorios requeridos
if settings.log_to_file:
import os
log_dir = os.path.dirname(settings.log_file_path)
if not os.path.exists(log_dir):
try:
os.makedirs(log_dir, exist_ok=True)
log.info("startup_check_passed", check="log_directory",
created=True)
except OSError as e:
log.error("startup_check_failed", check="log_directory",
error=str(e))
checks_passed = False
if not checks_passed and settings.is_production:
log.error("startup_checks_failed", action="exiting")
raise SystemExit(1)
log.info(
"startup_checks_completed",
all_passed=checks_passed,
environment=settings.environment
)
# En main.py:
# run_startup_checks()
# app = create_app()
Feature flags por entorno
# src/feature_flags.py
"""
Feature flags simples basados en configuración.
Para flags más dinámicos, usar LaunchDarkly u otro servicio.
"""
from src.config import get_settings
def is_guardrails_enabled() -> bool:
settings = get_settings()
return settings.enable_guardrails
def is_pii_redaction_enabled() -> bool:
settings = get_settings()
return settings.enable_pii_redaction
def should_log_full_prompt() -> bool:
settings = get_settings()
# Solo loguear el prompt completo si está explícitamente habilitado
# y no estamos en producción
return settings.log_full_prompt and not settings.is_production
def get_daily_budget_usd() -> float:
return get_settings().daily_budget_usd
# Uso en el código:
# if is_pii_redaction_enabled():
# text = redact_pii(text)
#
# if should_log_full_prompt():
# log.debug("full_prompt", prompt=prompt)
Tests con override de entorno
# tests/conftest.py
import pytest
from unittest.mock import patch
from src.config import Settings, get_settings
@pytest.fixture
def dev_settings() -> Settings:
"""Settings de desarrollo para tests de unit testing."""
return Settings(
environment="development",
use_mock_llm=True,
log_level="WARNING", # Silenciar logs en tests
debug=False,
openai_api_key="sk-test-key",
daily_budget_usd=0.0 # Sin budget en tests
)
@pytest.fixture
def prod_settings() -> Settings:
"""Settings de producción para verificar las validaciones."""
# No tiene openai_api_key real — no es para llamar al LLM
return Settings(
environment="production",
use_mock_llm=False,
log_level="INFO",
openai_api_key="sk-prod-test-key",
daily_budget_usd=50.0
)
@pytest.fixture(autouse=True)
def override_settings_for_tests(dev_settings):
"""Override automático para todos los tests."""
get_settings.cache_clear()
with patch("src.config.get_settings", return_value=dev_settings):
yield dev_settings
get_settings.cache_clear()
# tests/unit/test_environment_validation.py
def test_production_rejects_mock_llm():
"""En producción, use_mock_llm=True debe fallar la validación."""
with pytest.raises(ValueError, match="PRODUCCIÓN"):
Settings(
environment="production",
use_mock_llm=True,
openai_api_key="sk-valid-key",
log_level="INFO"
)
def test_production_requires_api_key():
"""En producción, openai_api_key vacía debe fallar la validación."""
with pytest.raises(ValueError, match="openai_api_key es requerido"):
Settings(
environment="production",
use_mock_llm=False,
openai_api_key="",
log_level="INFO"
)
def test_production_rejects_debug_log_level():
"""En producción, log_level=DEBUG debe fallar la validación."""
with pytest.raises(ValueError, match="log_level=DEBUG"):
Settings(
environment="production",
use_mock_llm=False,
openai_api_key="sk-valid-key",
log_level="DEBUG"
)
def test_staging_rejects_mock_llm():
"""En staging, use_mock_llm=True debe fallar la validación."""
with pytest.raises(ValueError, match="STAGING"):
Settings(
environment="staging",
use_mock_llm=True,
openai_api_key="sk-valid-key"
)
def test_development_allows_mock_and_debug():
"""En desarrollo, mock y debug son permitidos."""
settings = Settings(
environment="development",
use_mock_llm=True,
log_level="DEBUG",
debug=True
)
assert settings.use_mock_llm is True
assert settings.log_level == "DEBUG"
Ejercicios
Ejercicio 1: Matriz de configuración
Para tu app de análisis de sentimiento, crea la tabla completa de configuración por entorno. Incluye al menos 8 variables:
Ver solución
| Variable | Development | Staging | Production |
|---|---|---|---|
ENVIRONMENT | development | staging | production |
USE_MOCK_LLM | true | false | false |
MODEL | gpt-4o-mini | gpt-4o-mini | gpt-4o-mini |
TEMPERATURE | 0.0 | 0.0 | 0.0 |
LOG_LEVEL | DEBUG | INFO | INFO |
LOG_FULL_PROMPT | true | false | false |
DAILY_BUDGET_USD | 1.0 | 10.0 | 100.0 |
ENABLE_GUARDRAILS | false | true | true |
RETRY_ATTEMPTS | 1 | 3 | 3 |
DEBUG | true | false | false |
Ejercicio 2: Añadir un startup check
Escribe un startup check que verifique que el modelo configurado existe en OpenAI (hint: client.models.retrieve(model)):
Ver guía
def check_model_exists(settings: Settings) -> bool:
if settings.use_mock_llm:
return True # No verificar en mock
try:
client = settings.create_openai_client()
client.models.retrieve(settings.model)
log.info("startup_check_passed", check="model_exists", model=settings.model)
return True
except Exception as e:
log.error("startup_check_failed", check="model_exists",
model=settings.model, error=str(e)[:100])
return False
Resumen
- 3 entornos claros: development (mock, debug), staging (real LLM, budget limitado), production (full guardrails, monitoring)
- Validaciones por entorno en pydantic-settings: prohibiciones hard que no pueden ser overrideadas (
use_mock_llmen producción) - Startup checks: verificar configuración antes del primer request — "fail fast"
.envfiles:.envbase +.env.{env}override, secretos solo en variables de entorno del servidor- Feature flags: derivados de
Settings, simples y predecibles
Recursos adicionales
- 12-Factor App — Dev/Prod Parity — Principio de paridad entre entornos
- Secret Management (AWS) — Para secrets en producción
- HashiCorp Vault — Secret manager open source
- Feature Flags Best Practices — Martin Fowler
- Docker Compose para entornos — Manejo de env vars en Docker