Module 6: Code Quality Patterns for AI
4. Config Management
Description
The configuration of an AI app is more complex than that of a traditional web app: temperature, model, max_tokens, retry parameters, system prompts, budget limits, and API keys. With pydantic-settings, all that configuration lives in one place, with types, validation, and automatic reading from environment variables. This capsule implements a complete configuration system.
The problem of scattered configuration
# Before: config scattered across multiple places
# 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")
# To change MAX_TOKENS in staging:
# → Search in the 4 files
# → There's no type validation
# → If you write MAKS_TOKENS in .env, no error — it silently uses the default
# After: everything in a centralized Settings
# settings = get_settings()
# settings.model → "gpt-4o-mini"
# settings.temperature → 0.7
# settings.max_tokens → 500
# If MAKS_TOKENS is in .env, pydantic ignores it (it's not in the model)
# If MAX_TOKENS="abc", pydantic raises ValidationError at startup
pydantic-settings: type-safe configuration
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):
"""
Centralized configuration for the AI app.
Reads automatically from:
1. Environment variables (highest priority)
2. .env.{ENVIRONMENT} if it exists
3. .env (fallback)
4. Defaults in the model (lowest priority)
Example .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" # Ignore undefined variables (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: doesn't appear in repr, logs, or 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 # Input character limit
max_input_tokens: int = 4_000 # Token limit (with 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: declarative validation
# src/config.py (continued)
class Settings(BaseSettings):
# ... (fields from the previous block) ...
@field_validator("temperature")
@classmethod
def validate_temperature(cls, v: float) -> float:
if not 0.0 <= v <= 2.0:
raise ValueError(f"temperature must be in [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 must be in [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 must be one of {allowed}")
return v_upper
@field_validator("model")
@classmethod
def validate_model(cls, v: str) -> str:
# Don't validate against a strict list — OpenAI adds models frequently
# Just validate that it's not empty
if not v.strip():
raise ValueError("model cannot be empty")
return v.strip()
@model_validator(mode="after")
def validate_production_requirements(self) -> "Settings":
"""Validations that depend on multiple fields."""
if self.environment == "production":
if self.use_mock_llm:
raise ValueError(
"use_mock_llm=True is not allowed in production. "
"Set USE_MOCK_LLM=false"
)
if not self.openai_api_key.get_secret_value():
raise ValueError(
"openai_api_key is required in production. "
"Set OPENAI_API_KEY=sk-..."
)
if self.log_level == "DEBUG":
raise ValueError(
"log_level=DEBUG is not allowed in production "
"(it can expose sensitive information)"
)
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 to create the OpenAI client with the configured API key."""
from openai import OpenAI
api_key = self.openai_api_key.get_secret_value()
if not api_key:
raise ValueError("OPENAI_API_KEY not configured")
return OpenAI(api_key=api_key)
def get_llm_params(self) -> dict:
"""Parameters for the LLM call as a 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:
"""
Load and cache the configuration.
Why lru_cache():
- pydantic-settings reads .env on every instantiation (I/O)
- In production, the config doesn't change between requests
- lru_cache() reads the .env only once at startup
For tests: use the override_settings() fixture
"""
return Settings()
Loading per environment with multiple .env files
# Pattern: .env as the base, .env.{environment} as the override
import os
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
def _get_env_files() -> list[str]:
"""
Return the .env files to load in priority order (last = highest).
Order:
1. .env (base, lowest priority)
2. .env.{environment} (environment-specific override)
3. System environment variables (highest priority, always)
"""
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"
# ... rest of the fields ...
# File structure:
# .env → Common variables (no secrets)
# .env.development → Dev overrides (use_mock_llm=true, debug=true)
# .env.staging → Staging overrides (budget caps, real LLM)
# .env.production → Prod overrides (secrets in Vault or env vars, not here)
# .env.testing → Test overrides (mock=true, log_level=WARNING)
# .env.local → Personal local override (in .gitignore)
Example .env files
# .env (base — committed, no 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 (dev overrides)
ENVIRONMENT=development
TEMPERATURE=0.0
DEBUG=true
USE_MOCK_LLM=true
LOG_LEVEL=DEBUG
DAILY_BUDGET_USD=1.0
# .env.staging (staging overrides)
ENVIRONMENT=staging
USE_MOCK_LLM=false
LOG_LEVEL=INFO
DAILY_BUDGET_USD=10.0
ENABLE_INJECTION_CHECK=true
# .env.example (documentation — committed, no real values)
# Copy this to .env and fill in the values
# REQUIRED in production:
OPENAI_API_KEY=
# Optional (has reasonable defaults):
ENVIRONMENT=development
MODEL=gpt-4o-mini
TEMPERATURE=0.0
MAX_TOKENS=500
LOG_LEVEL=INFO
USE_MOCK_LLM=false
DAILY_BUDGET_USD=10.0
Using SecretStr for secrets
# Why SecretStr instead of str for api_key?
from pydantic import SecretStr
# With str:
settings = Settings(openai_api_key="sk-abcdef123456")
print(settings) # openai_api_key='sk-abcdef123456' ← IN LOGS!
print(repr(settings.openai_api_key)) # 'sk-abcdef123456' ← EXPOSED
log.info("settings loaded", settings=settings.model_dump()) # ← API KEY IN LOGS!
# With SecretStr:
settings = Settings(openai_api_key=SecretStr("sk-abcdef123456"))
print(settings) # openai_api_key=SecretStr('**********') ← MASKED
print(repr(settings.openai_api_key)) # SecretStr('**********') ← MASKED
# To use the value:
api_key = settings.openai_api_key.get_secret_value() # Explicit
client = OpenAI(api_key=api_key)
# ❌ This does expose the value:
log.info("api_key", key=settings.openai_api_key.get_secret_value())
# ✅ Only extract where needed, don't log it
Testing with injected configuration
# tests/conftest.py
import pytest
from unittest.mock import patch
from src.config import Settings, get_settings
@pytest.fixture
def test_settings() -> Settings:
"""Settings for tests: always mock, never calls the real LLM."""
return Settings(
environment="testing",
openai_api_key="sk-test-key-not-real",
use_mock_llm=True,
log_level="WARNING", # Less noise in tests
model="gpt-4o-mini",
temperature=0.0,
max_tokens=500
)
@pytest.fixture
def override_settings(test_settings: Settings):
"""
Override of get_settings() for all tests.
Avoids reading the real .env during tests.
"""
with patch("src.config.get_settings", return_value=test_settings):
# Clear the lru_cache cache
get_settings.cache_clear()
yield test_settings
get_settings.cache_clear()
# Usage in tests:
def test_something_with_config(override_settings):
settings = get_settings() # Returns test_settings, doesn't read .env
assert settings.use_mock_llm is True
assert settings.environment == "testing"
# Alternative: inline override with monkeypatch
def test_with_monkeypatch(monkeypatch):
monkeypatch.setenv("MODEL", "gpt-4o")
monkeypatch.setenv("TEMPERATURE", "0.5")
get_settings.cache_clear()
settings = Settings() # Reads the env vars from monkeypatch
assert settings.model == "gpt-4o"
assert settings.temperature == 0.5
Exercises
Exercise 1: Add fields to Settings
Add the following fields to your Settings with reasonable default values:
max_input_length: int— maximum characters of the user inputenable_guardrails: bool— enable or disable the guardrailsfallback_model: str— alternative model if the primary one fails
See solution
class Settings(BaseSettings):
# ...
max_input_length: int = 10_000 # 10K chars is reasonable for analysis
enable_guardrails: bool = True # Always on by default
fallback_model: str = "gpt-4o-mini" # Same model as fallback (cheaper)
@field_validator("max_input_length")
@classmethod
def validate_max_input(cls, v: int) -> int:
if v < 10:
raise ValueError("max_input_length must be >= 10")
if v > 100_000:
raise ValueError("max_input_length > 100K may be very expensive")
return v
Exercise 2: Detect configuration errors at startup
What's the problem with this code?
@app.post("/analyze")
async def analyze(body: AnalyzeRequest):
api_key = os.getenv("OPENAI_API_KEY") # Reads on every request
if not api_key:
raise HTTPException(500, "API key not configured")
client = OpenAI(api_key=api_key)
...
See solution
Problem: The error is detected on the first request, not at app startup. If OPENAI_API_KEY is not configured, the app starts without error and fails only when a user makes a request.
Solution with pydantic-settings: the model_validator with mode="after" verifies at startup that the API key is present in production. If it's missing, the server doesn't start (ValidationError before FastAPI starts receiving requests).
# With Settings, the error is at startup:
settings = get_settings() # ← ValidationError here if api_key is missing in prod
# The app never starts → the error is visible immediately
"Fail fast": better to crash at startup with a clear error than to serve requests that are going to fail.
Exercise 3: Per-environment configuration
Write the .env.development and .env.production files for an app where:
- In dev: use mock LLM, DEBUG logging, $1/day budget
- In prod: real LLM, INFO logging, $50/day budget
See solution
# .env.development
ENVIRONMENT=development
USE_MOCK_LLM=true
LOG_LEVEL=DEBUG
DAILY_BUDGET_USD=1.0
TEMPERATURE=0.0
ENABLE_INJECTION_CHECK=false # Less restrictive in dev to ease 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 is set as an environment variable on the server, NOT in .env
Summary
- pydantic-settings centralizes all configuration in a Python model with types, defaults, and validation
SecretStrfor secrets: they don't appear in repr, logs, or automatic dumpsmodel_validatorat startup: detects incorrect configuration before receiving the first request- Multiple
.envfiles:.envas the base +.env.{environment}as the override lru_cache()inget_settings(): the config is read only once at startup, not on every request- Tests:
override_settingsfixture to inject test config without reading the real.env
Additional resources
- pydantic-settings Documentation — Complete official documentation
- SecretStr in Pydantic — Types for secrets
- 12-Factor App — Config — The philosophy behind config management
- python-dotenv — For loading .env in Python
- Pydantic Validators — field_validator and model_validator