diff --git a/config.yaml.example b/config.yaml.example index 05edcbc9..975223fa 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -62,3 +62,18 @@ chat_model_n_ctx: 32768 # Number of layers to offload to GPU. If -1, all layers are offloaded. chat_model_n_gpu_layers: -1 + +# External logger configuration +external_loggers: + litellm: false # Enable/disable LiteLLM logging (includes LiteLLM Proxy, Router, and core) + sqlalchemy: false # Enable/disable SQLAlchemy logging + uvicorn.error: false # Enable/disable Uvicorn error logging + aiosqlite: false # Enable/disable aiosqlite logging + +# Note: External logger configuration can be overridden by: +# 1. Environment variables: +# CODEGATE_ENABLE_LITELLM=true # Controls all LiteLLM loggers +# CODEGATE_ENABLE_SQLALCHEMY=true +# CODEGATE_ENABLE_UVICORN_ERROR=true +# CODEGATE_ENABLE_AIOSQLITE=true + diff --git a/docs/cli.md b/docs/cli.md index 83c3d6aa..4dfef422 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -217,4 +217,4 @@ Generate certificates with default settings: codegate generate-certs ``` - + \ No newline at end of file diff --git a/docs/configuration.md b/docs/configuration.md index 67058151..6f4c16f4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -36,6 +36,13 @@ Values from higher-priority sources take precedence over lower-priority values. - Server certificate: `"server.crt"` - Server private key: `"server.key"` +- External logger configuration + - external_loggers: + - litellm: `false` + - sqlalchemy: `false` + - uvicorn.error: `false` + - aiosqlite: `false` + ## Configuration methods ### Configuration file @@ -85,6 +92,10 @@ Environment variables are automatically loaded with these mappings: - `CODEGATE_CA_KEY`: CA key file name - `CODEGATE_SERVER_CERT`: server certificate file name - `CODEGATE_SERVER_KEY`: server key file name +- `CODEGATE_ENABLE_LITELLM`: enable LiteLLM logging +- `CODEGATE_ENABLE_SQLALCHEMY`: enable SQLAlchemy logging +- `CODEGATE_ENABLE_UVICORN_ERROR`: enable Uvicorn error logging +- `CODEGATE_ENABLE_AIOSQLITE`: enable aiosqlite logging ```python config = Config.from_env() @@ -200,6 +211,20 @@ Available log formats (case-insensitive): - `JSON` - `TEXT` +### External logger configuration + +External loggers can be configured in several ways: + +1. Configuration file: + + ```yaml + external_loggers: + litellm: false + sqlalchemy: false + uvicorn.error: false + aiosqlite: false + ``` + ### Prompts configuration Prompts can be configured in several ways: diff --git a/docs/logging.md b/docs/logging.md index a88cb212..954800e0 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -10,6 +10,34 @@ Logs are automatically routed based on their level: - **stdout**: INFO and DEBUG messages - **stderr**: ERROR, CRITICAL, and WARNING messages +## External Logger Configuration + +CodeGate provides control over external loggers through configuration: + +### LiteLLM Logging + +LiteLLM logging can be controlled through: + +1. Environment variable: + ```bash + export CODEGATE_ENABLE_LITELLM=true + ``` + +2. Configuration file: + ```yaml + external_loggers: + litellm: true # Enable/disable LiteLLM logging (includes LiteLLM Proxy, Router, and core) + sqlalchemy: false # Enable/disable SQLAlchemy logging + uvicorn.error: false # Enable/disable Uvicorn error logging + aiosqlite: false # Enable/disable aiosqlite logging + ``` + +The configuration follows the priority order: +1. CLI arguments (highest priority) +2. Environment variables +3. Config file +4. Default values (lowest priority) + ## Log formats ### JSON format @@ -45,6 +73,7 @@ YYYY-MM-DDThh:mm:ss.mmmZ - LEVEL - NAME - MESSAGE - **Exception support**: full exception and stack trace integration - **Dual output**: separate handlers for error and non-error logs - **Configurable levels**: support for ERROR, WARNING, INFO, and DEBUG levels +- **External logger control**: fine-grained control over third-party logging ## Usage examples @@ -86,24 +115,21 @@ except Exception as e: The logging system can be configured through: -1. CLI arguments: - - ```bash - codegate serve --log-level DEBUG --log-format TEXT - ``` - -2. Environment variables: +1. Environment variables: ```bash - export APP_LOG_LEVEL=DEBUG + export CODEGATE_APP_LOG_LEVEL=DEBUG export CODEGATE_LOG_FORMAT=TEXT + export CODEGATE_ENABLE_LITELLM=true ``` -3. Configuration file: +2. Configuration file: ```yaml log_level: DEBUG log_format: TEXT + external_loggers: + litellm: true ``` ## Best practices @@ -129,3 +155,7 @@ The logging system can be configured through: better log aggregation and analysis. 4. Enable `DEBUG` level logging during development for maximum visibility. + +5. Configure external loggers based on your needs: + - Enable LiteLLM logging when debugging LLM-related issues + - Keep external loggers disabled in production unless needed for troubleshooting diff --git a/src/codegate/cli.py b/src/codegate/cli.py index 7bcd035d..8a428ab9 100644 --- a/src/codegate/cli.py +++ b/src/codegate/cli.py @@ -4,7 +4,7 @@ import signal import sys from pathlib import Path -from typing import Dict, Optional +from typing import Optional import click import structlog @@ -270,8 +270,8 @@ def serve( ) -> None: """Start the codegate server.""" try: - # Create provider URLs dict from CLI options - cli_provider_urls: Dict[str, str] = {} + # Create provider URLs dictionary from CLI arguments + cli_provider_urls = {} if vllm_url: cli_provider_urls["vllm"] = vllm_url if openai_url: @@ -281,6 +281,7 @@ def serve( if ollama_url: cli_provider_urls["ollama"] = ollama_url + # Load configuration with priority resolution cfg = Config.load( config_path=config, @@ -290,7 +291,7 @@ def serve( cli_host=host, cli_log_level=log_level, cli_log_format=log_format, - cli_provider_urls=cli_provider_urls, + cli_provider_urls=cli_provider_urls if cli_provider_urls else None, model_base_path=model_base_path, embedding_model=embedding_model, certs_dir=certs_dir, @@ -302,8 +303,8 @@ def serve( vec_db_path=vec_db_path, ) - # Set up logging first - setup_logging(cfg.log_level, cfg.log_format) + # Initialize logging + setup_logging(cfg.log_level, cfg.log_format, cfg.external_loggers) logger = structlog.get_logger("codegate").bind(origin="cli") init_db_sync(cfg.db_path) @@ -505,7 +506,7 @@ def generate_certs( cli_log_level=log_level, cli_log_format=log_format, ) - setup_logging(cfg.log_level, cfg.log_format) + setup_logging(cfg.log_level, cfg.log_format, cfg.external_loggers) logger = structlog.get_logger("codegate").bind(origin="cli") ca = CertificateAuthority.get_instance() diff --git a/src/codegate/codegate_logging.py b/src/codegate/codegate_logging.py index 36d0d351..67c7efdb 100644 --- a/src/codegate/codegate_logging.py +++ b/src/codegate/codegate_logging.py @@ -48,6 +48,47 @@ def _missing_(cls, value: str) -> Optional["LogFormat"]: ) +# Define all LiteLLM logger names +LITELLM_LOGGERS = ["LiteLLM Proxy", "LiteLLM Router", "LiteLLM"] + + +def configure_litellm_logging(enabled: bool = False, level: LogLevel = LogLevel.INFO) -> None: + """Configure LiteLLM logging. + + Args: + enabled: Whether to enable LiteLLM logging + level: Log level to use if enabled + """ + # Configure the main litellm logger + logger = logging.getLogger("litellm") + logger.disabled = not enabled + if not enabled: + logger.setLevel(logging.CRITICAL + 1) # Effectively disables all logging + else: + logger.setLevel(getattr(logging, level.value)) + logger.propagate = False + # Clear any existing handlers + logger.handlers.clear() + # Add a handler to ensure logs are properly routed + handler = logging.StreamHandler() + handler.setLevel(getattr(logging, level.value)) + logger.addHandler(handler) + + # Also configure the specific LiteLLM loggers + for logger_name in LITELLM_LOGGERS: + logger = logging.getLogger(logger_name) + logger.disabled = not enabled + if not enabled: + logger.setLevel(logging.CRITICAL + 1) + else: + logger.setLevel(getattr(logging, level.value)) + logger.propagate = False + logger.handlers.clear() + handler = logging.StreamHandler() + handler.setLevel(getattr(logging, level.value)) + logger.addHandler(handler) + + def add_origin(logger, log_method, event_dict): # Add 'origin' if it's bound to the logger but not explicitly in the event dict if "origin" not in event_dict and hasattr(logger, "_context"): @@ -58,13 +99,17 @@ def add_origin(logger, log_method, event_dict): def setup_logging( - log_level: Optional[LogLevel] = None, log_format: Optional[LogFormat] = None + log_level: Optional[LogLevel] = None, + log_format: Optional[LogFormat] = None, + external_loggers: Optional[Dict[str, bool]] = None, ) -> logging.Logger: """Configure the logging system. Args: log_level: The logging level to use. Defaults to INFO if not specified. log_format: The log format to use. Defaults to JSON if not specified. + external_loggers: Dictionary of external logger names and whether they should be enabled. + e.g. {"litellm": False, "sqlalchemy": False, "uvicorn.error": False} This configures two handlers: - stderr_handler: For ERROR, CRITICAL, and WARNING messages @@ -74,6 +119,16 @@ def setup_logging( log_level = LogLevel.INFO if log_format is None: log_format = LogFormat.JSON + if external_loggers is None: + external_loggers = { + "litellm": False, + "sqlalchemy": False, + "uvicorn.error": False, + "aiosqlite": False, + } + + # Configure LiteLLM logging based on external_loggers setting + configure_litellm_logging(enabled=external_loggers.get("litellm", False), level=log_level) # The configuration was taken from structlog documentation # https://www.structlog.org/en/stable/standard-library.html diff --git a/src/codegate/config.py b/src/codegate/config.py index 3f99fd04..f07ac948 100644 --- a/src/codegate/config.py +++ b/src/codegate/config.py @@ -38,6 +38,16 @@ class Config: log_format: LogFormat = LogFormat.JSON prompts: PromptConfig = field(default_factory=PromptConfig) + # External logger configuration + external_loggers: Dict[str, bool] = field( + default_factory=lambda: { + "litellm": False, + "sqlalchemy": False, + "uvicorn.error": False, + "aiosqlite": False, + } + ) + model_base_path: str = "./codegate_volume/models" chat_model_n_ctx: int = 32768 chat_model_n_gpu_layers: int = -1 @@ -129,6 +139,14 @@ def from_file(cls, config_path: Union[str, Path]) -> "Config": if "provider_urls" in config_data: provider_urls.update(config_data.pop("provider_urls")) + # Get default external loggers + default_external_loggers = { + "litellm": False, + "sqlalchemy": False, + "uvicorn.error": False, + "aiosqlite": False, + } + return cls( port=config_data.get("port", cls.port), proxy_port=config_data.get("proxy_port", cls.proxy_port), @@ -151,6 +169,7 @@ def from_file(cls, config_path: Union[str, Path]) -> "Config": force_certs=config_data.get("force_certs", cls.force_certs), prompts=prompts_config, provider_urls=provider_urls, + external_loggers=config_data.get("external_loggers", default_external_loggers), ) except yaml.YAMLError as e: raise ConfigurationError(f"Failed to parse config file: {e}") @@ -162,12 +181,15 @@ def from_env(cls) -> "Config": """Load configuration from environment variables. Returns: - Config: Configuration instance + Config: Configuration instance with values from environment variables. + + Raises: + ConfigurationError: If an environment variable has an invalid value. """ try: - # Start with default prompts - config = cls(prompts=cls._load_default_prompts()) + config = cls() + # Load basic configuration if "CODEGATE_APP_PORT" in os.environ: config.port = int(os.environ["CODEGATE_APP_PORT"]) if "CODEGATE_APP_PROXY_PORT" in os.environ: @@ -175,15 +197,15 @@ def from_env(cls) -> "Config": if "CODEGATE_APP_HOST" in os.environ: config.host = os.environ["CODEGATE_APP_HOST"] if "CODEGATE_APP_LOG_LEVEL" in os.environ: - config.log_level = LogLevel(os.environ["CODEGATE_APP_LOG_LEVEL"]) + config.log_level = LogLevel(os.environ["CODEGATE_APP_LOG_LEVEL"].upper()) if "CODEGATE_LOG_FORMAT" in os.environ: - config.log_format = LogFormat(os.environ["CODEGATE_LOG_FORMAT"]) + config.log_format = LogFormat(os.environ["CODEGATE_LOG_FORMAT"].upper()) if "CODEGATE_PROMPTS_FILE" in os.environ: - config.prompts = PromptConfig.from_file( - os.environ["CODEGATE_PROMPTS_FILE"] - ) # noqa: E501 - - # Load certificate configuration from environment + config.prompts = PromptConfig.from_file(os.environ["CODEGATE_PROMPTS_FILE"]) + if "CODEGATE_MODEL_BASE_PATH" in os.environ: + config.model_base_path = os.environ["CODEGATE_MODEL_BASE_PATH"] + if "CODEGATE_EMBEDDING_MODEL" in os.environ: + config.embedding_model = os.environ["CODEGATE_EMBEDDING_MODEL"] if "CODEGATE_CERTS_DIR" in os.environ: config.certs_dir = os.environ["CODEGATE_CERTS_DIR"] if "CODEGATE_CA_CERT" in os.environ: @@ -207,6 +229,12 @@ def from_env(cls) -> "Config": if env_var in os.environ: config.provider_urls[provider] = os.environ[env_var] + # Load external logger configuration from environment variables + for logger_name in config.external_loggers.keys(): + env_var = f"CODEGATE_ENABLE_{logger_name.upper().replace('.', '_')}" + if env_var in os.environ: + config.external_loggers[logger_name] = os.environ[env_var].lower() == "true" + return config except ValueError as e: raise ConfigurationError(f"Invalid environment variable value: {e}") @@ -222,6 +250,7 @@ def load( cli_log_level: Optional[str] = None, cli_log_format: Optional[str] = None, cli_provider_urls: Optional[Dict[str, str]] = None, + cli_external_loggers: Optional[Dict[str, bool]] = None, model_base_path: Optional[str] = None, embedding_model: Optional[str] = None, certs_dir: Optional[str] = None, @@ -250,6 +279,7 @@ def load( cli_log_level: Optional CLI log level override cli_log_format: Optional CLI log format override cli_provider_urls: Optional dict of provider URLs from CLI + cli_external_loggers: Optional dict of external logger configuration from CLI model_base_path: Optional path to model base directory embedding_model: Optional name of the model to use for embeddings certs_dir: Optional path to certificates directory @@ -306,8 +336,6 @@ def load( config.server_cert = env_config.server_cert if "CODEGATE_SERVER_KEY" in os.environ: config.server_key = env_config.server_key - if "CODEGATE_FORCE_CERTS" in os.environ: - config.force_certs = env_config.force_certs if "CODEGATE_DB_PATH" in os.environ: config.db_path = env_config.db_path if "CODEGATE_VEC_DB_PATH" in os.environ: @@ -317,6 +345,12 @@ def load( for provider, url in env_config.provider_urls.items(): config.provider_urls[provider] = url + # Override external logger configuration from environment + for logger_name, enabled in env_config.external_loggers.items(): + env_var = f"CODEGATE_ENABLE_{logger_name.upper().replace('.', '_')}" + if env_var in os.environ: + config.external_loggers[logger_name] = enabled + # Override with CLI arguments if cli_port is not None: config.port = cli_port @@ -352,6 +386,8 @@ def load( config.vec_db_path = vec_db_path if force_certs is not None: config.force_certs = force_certs + if cli_external_loggers is not None: + config.external_loggers.update(cli_external_loggers) # Set the __config class attribute Config.__config = config diff --git a/tests/test_config.py b/tests/test_config.py index 94037393..bb401328 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -240,3 +240,84 @@ def test_env_var_priority(config_file_with_format: Path) -> None: assert config.log_format == LogFormat.JSON # env var overrides file finally: del os.environ["CODEGATE_LOG_FORMAT"] + + +def test_external_loggers_from_env() -> None: + """Test loading external logger configuration from environment variables.""" + os.environ.update( + { + "CODEGATE_ENABLE_LITELLM": "true", + "CODEGATE_ENABLE_SQLALCHEMY": "true", + "CODEGATE_ENABLE_UVICORN_ERROR": "false", + "CODEGATE_ENABLE_AIOSQLITE": "false", + } + ) + try: + config = Config.from_env() + assert config.external_loggers["litellm"] is True + assert config.external_loggers["sqlalchemy"] is True + assert config.external_loggers["uvicorn.error"] is False + assert config.external_loggers["aiosqlite"] is False + finally: + for key in [ + "CODEGATE_ENABLE_LITELLM", + "CODEGATE_ENABLE_SQLALCHEMY", + "CODEGATE_ENABLE_UVICORN_ERROR", + "CODEGATE_ENABLE_AIOSQLITE", + ]: + os.environ.pop(key, None) + + +def test_external_loggers_from_config_file(tmp_path: Path) -> None: + """Test loading external logger configuration from config file.""" + config_file = tmp_path / "config.yaml" + config_data = { + "external_loggers": { + "litellm": True, + "sqlalchemy": False, + "uvicorn.error": True, + "aiosqlite": False, + } + } + with open(config_file, "w") as f: + yaml.dump(config_data, f) + + config = Config.from_file(config_file) + assert config.external_loggers["litellm"] is True + assert config.external_loggers["sqlalchemy"] is False + assert config.external_loggers["uvicorn.error"] is True + assert config.external_loggers["aiosqlite"] is False + + +def test_external_loggers_defaults() -> None: + """Test default values for external loggers.""" + config = Config() + assert config.external_loggers["litellm"] is False + assert config.external_loggers["sqlalchemy"] is False + assert config.external_loggers["uvicorn.error"] is False + assert config.external_loggers["aiosqlite"] is False + + +def test_external_loggers_env_override_config(tmp_path: Path) -> None: + """Test environment variables override config file for external loggers.""" + config_file = tmp_path / "config.yaml" + config_data = { + "external_loggers": { + "litellm": False, + "sqlalchemy": False, + "uvicorn.error": False, + "aiosqlite": False, + } + } + with open(config_file, "w") as f: + yaml.dump(config_data, f) + + os.environ["CODEGATE_ENABLE_LITELLM"] = "true" + try: + config = Config.load(config_path=config_file) + assert config.external_loggers["litellm"] is True # env var overrides file + assert config.external_loggers["sqlalchemy"] is False # from file + assert config.external_loggers["uvicorn.error"] is False # from file + assert config.external_loggers["aiosqlite"] is False # from file + finally: + del os.environ["CODEGATE_ENABLE_LITELLM"] diff --git a/tests/test_logging.py b/tests/test_logging.py index 81bf3011..9436d261 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -36,3 +36,37 @@ def test_logging_stream_output(): log_output.seek(0) formatted_log = log_output.getvalue().strip() assert "Debug message" in formatted_log + + +def test_external_logger_configuration(): + # Test enabling litellm logging + setup_logging( + log_level=LogLevel.DEBUG, log_format=LogFormat.TEXT, external_loggers={"litellm": True} + ) + litellm_logger = logging.getLogger("litellm") + assert not litellm_logger.disabled + assert litellm_logger.level == logging.DEBUG + + # Test disabling litellm logging + setup_logging( + log_level=LogLevel.DEBUG, log_format=LogFormat.TEXT, external_loggers={"litellm": False} + ) + litellm_logger = logging.getLogger("litellm") + assert litellm_logger.disabled + assert litellm_logger.level > logging.CRITICAL + + +def test_external_logger_defaults(): + # Test default behavior (all external loggers disabled) + setup_logging(log_level=LogLevel.DEBUG, log_format=LogFormat.TEXT) + + # Check all external loggers are disabled by default + litellm_logger = logging.getLogger("litellm") + sqlalchemy_logger = logging.getLogger("sqlalchemy") + uvicorn_logger = logging.getLogger("uvicorn.error") + aiosqlite_logger = logging.getLogger("aiosqlite") + + assert litellm_logger.disabled + assert sqlalchemy_logger.disabled + assert uvicorn_logger.disabled + assert aiosqlite_logger.disabled diff --git a/tests/test_server.py b/tests/test_server.py index 8e07c0ee..97d24e64 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -219,7 +219,11 @@ def test_serve_default_options(cli_runner): assert result.exit_code == 0 # Check if the logging setup was called with expected defaults - mock_setup_logging.assert_called_once_with(LogLevel.INFO, LogFormat.JSON) + mock_setup_logging.assert_called_once_with( + LogLevel.INFO, + LogFormat.JSON, + {"litellm": False, "sqlalchemy": False, "uvicorn.error": False, "aiosqlite": False}, + ) # Validate run_servers was called once mock_run.assert_called_once() @@ -260,9 +264,6 @@ def test_serve_custom_options(cli_runner): # Check the command executed successfully assert result.exit_code == 0 - # Assert logging setup was called with the provided log level and format - mock_setup_logging.assert_called_once_with(LogLevel.DEBUG, LogFormat.TEXT) - # Validate run_servers was called once mock_run.assert_called_once() # Retrieve the actual Config object passed to run_servers @@ -328,7 +329,11 @@ def test_serve_with_config_file(cli_runner, temp_config_file): # Assertions to ensure the CLI ran successfully assert result.exit_code == 0 - mock_setup_logging.assert_called_once_with(LogLevel.DEBUG, LogFormat.JSON) + mock_setup_logging.assert_called_once_with( + LogLevel.DEBUG, + LogFormat.JSON, + {"litellm": False, "sqlalchemy": False, "uvicorn.error": False, "aiosqlite": False}, + ) # Validate that run_servers was called with the expected configuration mock_run.assert_called_once() @@ -397,7 +402,11 @@ def test_serve_priority_resolution(cli_runner: CliRunner, temp_config_file: Path assert result.exit_code == 0 # Ensure logging setup was called with the highest priority settings (CLI arguments) - mock_setup_logging.assert_called_once_with("ERROR", "TEXT") + mock_setup_logging.assert_called_once_with( + LogLevel.ERROR, + LogFormat.TEXT, + {"litellm": False, "sqlalchemy": False, "uvicorn.error": False, "aiosqlite": False}, + ) # Verify that the run_servers was called with the overridden settings config_arg = mock_run.call_args[0][0] # Assuming Config is the first positional arg @@ -405,8 +414,8 @@ def test_serve_priority_resolution(cli_runner: CliRunner, temp_config_file: Path expected_values = { "port": 8080, "host": "example.com", - "log_level": "ERROR", - "log_format": "TEXT", + "log_level": LogLevel.ERROR, + "log_format": LogFormat.TEXT, "certs_dir": "./cli-certs", "ca_cert": "cli-ca.crt", "ca_key": "cli-ca.key", @@ -449,7 +458,11 @@ def test_serve_certificate_options(cli_runner: CliRunner) -> None: assert result.exit_code == 0 # Ensure logging setup was called with expected arguments - mock_setup_logging.assert_called_once_with("INFO", "JSON") + mock_setup_logging.assert_called_once_with( + LogLevel.INFO, + LogFormat.JSON, + {"litellm": False, "sqlalchemy": False, "uvicorn.error": False, "aiosqlite": False}, + ) # Verify that run_servers was called with the provided certificate options config_arg = mock_run.call_args[0][0] # Assuming Config is the first positional arg