Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/ai-service/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion app/ai-service/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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")],
Expand Down
3 changes: 2 additions & 1 deletion app/ai-service/api/v1/ocr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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")],
Expand Down
37 changes: 34 additions & 3 deletions app/ai-service/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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:
Expand Down
5 changes: 4 additions & 1 deletion app/ai-service/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down
38 changes: 38 additions & 0 deletions app/ai-service/tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading