diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/core/config.py b/src/core/config.py new file mode 100644 index 0000000..9ef727e --- /dev/null +++ b/src/core/config.py @@ -0,0 +1,86 @@ +""" +Global application configuration. +Following best practice of splitting config across domains. +""" +from enum import Enum +from functools import lru_cache + +from pydantic import Field, HttpUrl +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Environment(str, Enum): + """Application environment.""" + DEVELOPMENT = "development" + PRODUCTION = "production" + TESTING = "testing" + + +class Config(BaseSettings): + """ + Global application configuration. + + Loads from environment variables and .env file. + Domain-specific configs should be in their respective modules. + """ + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=True, + extra="ignore", + ) + + # Application + APP_NAME: str = "OpenAI-Compatible API" + APP_VERSION: str = "0.1.0" + ENVIRONMENT: Environment = Environment.DEVELOPMENT + DEBUG: bool = Field(default=False, description="Debug mode") + + # API Configuration + API_HOST: str = Field(default="0.0.0.0", description="API host") + API_PORT: int = Field(default=8000, description="API port") + API_PREFIX: str = Field(default="/v1", description="API route prefix") + + # Ollama Configuration + OLLAMA_HOST: HttpUrl = Field( + default="http://localhost:11434", + description="Ollama server URL" + ) + OLLAMA_DEFAULT_MODEL: str = Field( + default="mistral-nemo:latest", + description="Default Ollama model" + ) + OLLAMA_TIMEOUT: int = Field( + default=120, + description="Ollama request timeout in seconds" + ) + STREAM_TIMEOUT: int = Field( + default=20, + description="Timeout for each streaming turn in seconds" + ) + + # Logging + LOG_LEVEL: str = Field(default="INFO", description="Logging level") + + # CORS + CORS_ORIGINS: list[str] = Field( + default=["*"], + description="Allowed CORS origins" + ) + CORS_ALLOW_CREDENTIALS: bool = True + CORS_ALLOW_METHODS: list[str] = ["*"] + CORS_ALLOW_HEADERS: list[str] = ["*"] + + +@lru_cache +def get_config() -> Config: + """ + Get cached configuration instance. + + Uses lru_cache to ensure config is loaded once and reused. + """ + return Config() + + +# Global config instance +config = get_config() diff --git a/src/core/exceptions.py b/src/core/exceptions.py new file mode 100644 index 0000000..84f9d61 --- /dev/null +++ b/src/core/exceptions.py @@ -0,0 +1,52 @@ +""" +Global exception definitions. +Domain-specific exceptions should be in their respective modules. +""" +from typing import Any + + +class AppException(Exception): + """Base exception for all application errors.""" + + def __init__( + self, + message: str = "An error occurred", + status_code: int = 500, + details: dict[str, Any] | None = None, + ): + self.message = message + self.status_code = status_code + self.details = details or {} + super().__init__(self.message) + + +class OllamaConnectionError(AppException): + """Raised when cannot connect to Ollama service.""" + + def __init__(self, message: str = "Cannot connect to Ollama service"): + super().__init__(message=message, status_code=503) + + +class OllamaTimeoutError(AppException): + """Raised when Ollama request times out.""" + + def __init__(self, message: str = "Ollama request timed out"): + super().__init__(message=message, status_code=504) + + +class ModelNotFoundError(AppException): + """Raised when requested model is not available.""" + + def __init__(self, model_name: str): + super().__init__( + message=f"Model '{model_name}' not found", + status_code=404, + details={"model": model_name} + ) + + +class ValidationError(AppException): + """Raised for validation errors.""" + + def __init__(self, message: str, details: dict[str, Any] | None = None): + super().__init__(message=message, status_code=422, details=details) diff --git a/src/core/models.py b/src/core/models.py new file mode 100644 index 0000000..7329f44 --- /dev/null +++ b/src/core/models.py @@ -0,0 +1,43 @@ +""" +Custom Pydantic base models for consistent serialization. +Following best practice of having a global base model. +""" +from datetime import datetime +from typing import Any + +from fastapi.encoders import jsonable_encoder +from pydantic import BaseModel, ConfigDict + + +def datetime_to_iso_str(dt: datetime) -> str: + """Convert datetime to ISO format string.""" + return dt.isoformat() + + +class CustomBaseModel(BaseModel): + """ + Custom base model with consistent configuration. + + All domain models should inherit from this for: + - Consistent JSON serialization + - Timezone-aware datetime handling + - Alias population support + """ + model_config = ConfigDict( + json_encoders={datetime: datetime_to_iso_str}, + populate_by_name=True, + use_enum_values=True, + validate_assignment=True, + arbitrary_types_allowed=True, + ) + + def serializable_dict(self, **kwargs: Any) -> dict[str, Any]: + """ + Return dict with only JSON-serializable fields. + + Useful for logging and debugging. + """ + return jsonable_encoder( + self.model_dump(**kwargs), + custom_encoder={datetime: datetime_to_iso_str} + )