diff --git a/app/ai-service/.env.example b/app/ai-service/.env.example index aa0d2d23..8de6d603 100644 --- a/app/ai-service/.env.example +++ b/app/ai-service/.env.example @@ -11,6 +11,8 @@ TEST_PROVIDER_MODE=false LLM_TIMEOUT_SECONDS=30 # Application Settings +# APP_ENV values: development, staging, production, test +# Use staging for stable end-to-end testnet/testing with safe defaults. APP_ENV=development LOG_LEVEL=INFO HOST=0.0.0.0 diff --git a/app/ai-service/api/routes.py b/app/ai-service/api/routes.py index f1ad3b54..9e96b2de 100644 --- a/app/ai-service/api/routes.py +++ b/app/ai-service/api/routes.py @@ -9,6 +9,7 @@ from schemas.ocr import OCRData, OCRFieldResult, OCRResponse from services.ocr import OCRService +from config import settings router = APIRouter(tags=["ai"]) limiter = Limiter(key_func=get_remote_address) @@ -26,7 +27,7 @@ @router.post("/ai/ocr") -@limiter.limit("10/minute") +@limiter.limit(settings.request_rate_limit) async def process_ocr( request: Request, image: Annotated[UploadFile, File(description="Image file to process")], diff --git a/app/ai-service/api/v1/ocr.py b/app/ai-service/api/v1/ocr.py index 7afa6026..5fd170ae 100644 --- a/app/ai-service/api/v1/ocr.py +++ b/app/ai-service/api/v1/ocr.py @@ -16,6 +16,7 @@ from schemas.ocr import OCRData, OCRFieldResult, OCRResponse from services.ocr import OCRService +from config import settings router = APIRouter(tags=["ocr"]) limiter = Limiter(key_func=get_remote_address) @@ -33,7 +34,7 @@ @router.post("/ai/ocr") -@limiter.limit("10/minute") +@limiter.limit(settings.request_rate_limit) async def process_ocr( request: Request, image: Annotated[UploadFile, File(description="Image file to process")], diff --git a/app/ai-service/config.py b/app/ai-service/config.py index e7a011f4..8d62614b 100644 --- a/app/ai-service/config.py +++ b/app/ai-service/config.py @@ -3,9 +3,11 @@ Handles environment variables and API key management """ +from typing import Literal, Optional +from pydantic import model_validator from pydantic_settings import BaseSettings, SettingsConfigDict -from typing import Optional import logging +import os import secrets logger = logging.getLogger(__name__) @@ -23,7 +25,7 @@ class Settings(BaseSettings): AI_DETERMINISTIC_MODE: Enable deterministic AI results for verification and classification during tests/CI TEST_PROVIDER_MODE: Enable test provider mode that returns fixture-driven results (no API keys required) LLM_TIMEOUT_SECONDS: Timeout for LLM API requests - APP_ENV: Application environment (development, staging, production) + APP_ENV: Application environment (development, staging, production, test) LOG_LEVEL: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) HOST: Server host (default: 0.0.0.0) PORT: Server port (default: 8000) @@ -42,12 +44,15 @@ class Settings(BaseSettings): test_provider_mode: bool = False llm_timeout_seconds: int = 30 + # Request throttling + request_rate_limit: str = "10/minute" + # Circuit Breaker settings circuit_breaker_failure_threshold: int = 3 circuit_breaker_recovery_timeout_seconds: float = 30.0 # Application settings - app_env: str = "development" + app_env: Literal["development", "staging", "production", "test"] = "development" log_level: str = "INFO" host: str = "0.0.0.0" port: int = 8000 @@ -73,6 +78,32 @@ class Settings(BaseSettings): case_sensitive=False, ) + @model_validator(mode="after") + def apply_environment_defaults(self) -> "Settings": + if self.app_env == "staging": + self.request_rate_limit = "5/minute" + self.ai_deterministic_mode = True + if not (self.openai_api_key or self.groq_api_key or self.test_provider_mode): + self.test_provider_mode = True + + if self.app_env == "test": + self.request_rate_limit = "5/minute" + self.ai_deterministic_mode = True + if not (self.openai_api_key or self.groq_api_key or self.test_provider_mode): + self.test_provider_mode = True + + if self.app_env == "production": + if "LOG_LEVEL" not in os.environ: + self.log_level = "WARNING" + if self.request_rate_limit == "10/minute": + self.request_rate_limit = "20/minute" + if not (self.openai_api_key or self.groq_api_key or self.test_provider_mode): + raise ValueError( + "Production environment requires OPENAI_API_KEY, GROQ_API_KEY, or TEST_PROVIDER_MODE=true" + ) + + return self + def validate_api_keys(self) -> bool: has_key = bool(self.openai_api_key or self.groq_api_key or self.test_provider_mode) if not has_key: diff --git a/app/ai-service/main.py b/app/ai-service/main.py index 9dd8ad92..7691d1a7 100644 --- a/app/ai-service/main.py +++ b/app/ai-service/main.py @@ -41,8 +41,11 @@ limiter = Limiter(key_func=get_remote_address) +log_level_name = settings.log_level.upper() if hasattr(settings, "log_level") else "INFO" +log_level = getattr(logging, log_level_name, logging.INFO) logging.basicConfig( - level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" + level=log_level, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", ) logger = logging.getLogger(__name__) diff --git a/app/ai-service/tests/test_config.py b/app/ai-service/tests/test_config.py index d8975f93..57bf1bfd 100644 --- a/app/ai-service/tests/test_config.py +++ b/app/ai-service/tests/test_config.py @@ -39,3 +39,41 @@ def test_validate_api_keys_returns_true_when_test_provider_mode(monkeypatch): settings = Settings() assert settings.validate_api_keys() is True + + +def test_staging_environment_defaults_to_safe_test_settings(monkeypatch): + monkeypatch.setenv("APP_ENV", "staging") + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("GROQ_API_KEY", raising=False) + monkeypatch.delenv("TEST_PROVIDER_MODE", raising=False) + monkeypatch.delenv("LOG_LEVEL", raising=False) + monkeypatch.delenv("AI_DETERMINISTIC_MODE", raising=False) + + settings = Settings() + + assert settings.app_env == "staging" + assert settings.test_provider_mode is True + assert settings.ai_deterministic_mode is True + assert settings.request_rate_limit == "5/minute" + assert settings.log_level == "INFO" + assert settings.get_active_provider() == "test" + + +def test_production_environment_requires_provider_configuration(monkeypatch): + monkeypatch.setenv("APP_ENV", "production") + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("GROQ_API_KEY", raising=False) + monkeypatch.delenv("TEST_PROVIDER_MODE", raising=False) + monkeypatch.delenv("LOG_LEVEL", raising=False) + + with pytest.raises(ValueError): + Settings() + + +def test_production_environment_allows_test_provider_when_enabled(monkeypatch): + monkeypatch.setenv("APP_ENV", "production") + monkeypatch.setenv("TEST_PROVIDER_MODE", "true") + + settings = Settings() + + assert settings.get_active_provider() == "test"