From 399579f8ea3e60c025ee3d5e48ffce1de4e98385 Mon Sep 17 00:00:00 2001 From: amangupta-20 Date: Sun, 4 Jan 2026 21:19:21 -0600 Subject: [PATCH] feat: Add Levo AI integration (#18529) --- .../docs/observability/levo_integration.md | 162 +++++++ .../opentelemetry_integration.md | 2 +- docs/my-website/img/levo_logo.png | Bin 0 -> 11911 bytes docs/my-website/img/levo_logo_dark.png | Bin 0 -> 10967 bytes docs/my-website/src/css/custom.css | 31 ++ litellm/__init__.py | 1 + litellm/integrations/levo/README.md | 125 ++++++ litellm/integrations/levo/__init__.py | 3 + litellm/integrations/levo/levo.py | 117 +++++ .../custom_logger_registry.py | 1 + litellm/litellm_core_utils/litellm_logging.py | 25 ++ .../integrations/levo/__init__.py | 1 + .../integrations/levo/test_levo.py | 407 ++++++++++++++++++ 13 files changed, 874 insertions(+), 1 deletion(-) create mode 100644 docs/my-website/docs/observability/levo_integration.md create mode 100644 docs/my-website/img/levo_logo.png create mode 100644 docs/my-website/img/levo_logo_dark.png create mode 100644 litellm/integrations/levo/README.md create mode 100644 litellm/integrations/levo/__init__.py create mode 100644 litellm/integrations/levo/levo.py create mode 100644 tests/test_litellm/integrations/levo/__init__.py create mode 100644 tests/test_litellm/integrations/levo/test_levo.py diff --git a/docs/my-website/docs/observability/levo_integration.md b/docs/my-website/docs/observability/levo_integration.md new file mode 100644 index 0000000000..3e46cf6b92 --- /dev/null +++ b/docs/my-website/docs/observability/levo_integration.md @@ -0,0 +1,162 @@ +--- +sidebar_label: Levo AI +--- + +import Image from '@theme/IdealImage'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# Levo AI + +
+
+ +
+
+ +
+
+ +[Levo](https://levo.ai/) is an AI observability and compliance platform that provides comprehensive monitoring, analysis, and compliance tracking for LLM applications. + +## Quick Start + +Send all your LLM requests and responses to Levo for monitoring and analysis using LiteLLM's built-in Levo integration. + +### What You'll Get + +- **Complete visibility** into all LLM API calls across all providers +- **Request and response data** including prompts, completions, and metadata +- **Usage and cost tracking** with token counts and cost breakdowns +- **Error monitoring** and performance metrics +- **Compliance tracking** for audit and governance + +### Setup Steps + +**1. Install OpenTelemetry dependencies:** + +```bash +pip install opentelemetry-api opentelemetry-sdk opentelemetry-exporter-otlp-proto-http opentelemetry-exporter-otlp-proto-grpc +``` + +**2. Enable Levo callback in your LiteLLM config:** + +Add to your `litellm_config.yaml`: + +```yaml +litellm_settings: + callbacks: ["levo"] +``` + +**3. Configure environment variables:** + +[Contact Levo support](mailto:support@levo.ai) to get your collector endpoint URL, API key, organization ID, and workspace ID. + +Set these required environment variables: + +```bash +export LEVOAI_API_KEY="" +export LEVOAI_ORG_ID="" +export LEVOAI_WORKSPACE_ID="" +export LEVOAI_COLLECTOR_URL="" +``` + +**Note:** The collector URL should be the full endpoint URL provided by Levo support. It will be used exactly as provided. + +**4. Start LiteLLM:** + +```bash +litellm --config config.yaml +``` + +**5. Make requests - they'll automatically be sent to Levo!** + +```bash +curl --location 'http://0.0.0.0:4000/chat/completions' \ + --header 'Content-Type: application/json' \ + --data '{ + "model": "gpt-3.5-turbo", + "messages": [ + { + "role": "user", + "content": "Hello, this is a test message" + } + ] + }' +``` + +## What Data is Captured + +| Feature | Details | +|---------|---------| +| **What is logged** | OpenTelemetry Trace Data (OTLP format) | +| **Events** | Success + Failure | +| **Format** | OTLP (OpenTelemetry Protocol) | +| **Headers** | Automatically includes `Authorization: Bearer {LEVOAI_API_KEY}`, `x-levo-organization-id`, and `x-levo-workspace-id` | + +## Configuration Reference + +### Required Environment Variables + +| Variable | Description | Example | +|----------|-------------|---------| +| `LEVOAI_API_KEY` | Your Levo API key | `levo_abc123...` | +| `LEVOAI_ORG_ID` | Your Levo organization ID | `org-123456` | +| `LEVOAI_WORKSPACE_ID` | Your Levo workspace ID | `workspace-789` | +| `LEVOAI_COLLECTOR_URL` | Full collector endpoint URL from Levo support | `https://collector.levo.ai/v1/traces` | + +### Optional Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `LEVOAI_ENV_NAME` | Environment name for tagging traces | `None` | + +**Note:** The collector URL is used exactly as provided by Levo support. No path manipulation is performed. + +## Troubleshooting + +### Not seeing traces in Levo? + +1. **Verify Levo callback is enabled**: Check LiteLLM startup logs for `initializing callbacks=['levo']` + +2. **Check required environment variables**: Ensure all required variables are set: + ```bash + echo $LEVOAI_API_KEY + echo $LEVOAI_ORG_ID + echo $LEVOAI_WORKSPACE_ID + echo $LEVOAI_COLLECTOR_URL + ``` + +3. **Verify collector connectivity**: Test if your collector is reachable: + ```bash + curl /health + ``` + +4. **Check for initialization errors**: Look for errors in LiteLLM startup logs. Common issues: + - Missing OpenTelemetry packages: Install with `pip install opentelemetry-api opentelemetry-sdk opentelemetry-exporter-otlp-proto-http opentelemetry-exporter-otlp-proto-grpc` + - Missing required environment variables: All four required variables must be set + - Invalid collector URL: Ensure the URL is correct and reachable + +5. **Enable debug logging**: + ```bash + export LITELLM_LOG="DEBUG" + ``` + +6. **Wait for async export**: OTLP sends traces asynchronously. Wait 10-15 seconds after making requests before checking Levo. + +### Common Errors + +**Error: "LEVOAI_COLLECTOR_URL environment variable is required"** +- Solution: Set the `LEVOAI_COLLECTOR_URL` environment variable with your collector endpoint URL from Levo support. + +**Error: "No module named 'opentelemetry'"** +- Solution: Install OpenTelemetry packages: `pip install opentelemetry-api opentelemetry-sdk opentelemetry-exporter-otlp-proto-http opentelemetry-exporter-otlp-proto-grpc` + +## Additional Resources + +- [Levo Documentation](https://docs.levo.ai) +- [OpenTelemetry Specification](https://opentelemetry.io/docs/specs/otel/) + +## Need Help? + +For issues or questions about the Levo integration with LiteLLM, please [contact Levo support](mailto:support@levo.ai) or open an issue on the [LiteLLM GitHub repository](https://github.com/BerriAI/litellm/issues). diff --git a/docs/my-website/docs/observability/opentelemetry_integration.md b/docs/my-website/docs/observability/opentelemetry_integration.md index aa59d0aa3f..b6eff23162 100644 --- a/docs/my-website/docs/observability/opentelemetry_integration.md +++ b/docs/my-website/docs/observability/opentelemetry_integration.md @@ -4,7 +4,7 @@ import TabItem from '@theme/TabItem'; # OpenTelemetry - Tracing LLMs with any observability tool -OpenTelemetry is a CNCF standard for observability. It connects to any observability tool, such as Jaeger, Zipkin, Datadog, New Relic, Traceloop and others. +OpenTelemetry is a CNCF standard for observability. It connects to any observability tool, such as Jaeger, Zipkin, Datadog, New Relic, Traceloop, Levo AI and others. diff --git a/docs/my-website/img/levo_logo.png b/docs/my-website/img/levo_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..fdb72470b2927d85dba469be3df36a995aa200b1 GIT binary patch literal 11911 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 CuTW+H literal 0 HcmV?d00001 diff --git a/docs/my-website/img/levo_logo_dark.png b/docs/my-website/img/levo_logo_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..70da632ee9093716991ce05815bca4551522ca5f GIT binary patch literal 10967 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
g?7-z?A7u|; zsyz|z!Oqw?4(H^k%J+l~rf_Q4iO9)6(fs$ocUteRAsot_s$^a%y?r&E<>(RpGF=yU~a?GATJi3QUU#f*U)SgCS zbU{dB@;2`8DKFxLvMAFuQk=$^pD)Vp5@5Z)|5^*r)MfD zWRH7JowsnY{L*7IGQMB-34BW8Nwkp4pZBaHy1@@I5tOTb^((F-ihL^INWptluh%RMfv9hlxpqBERRb|Z+0W#SeIKM_o$XvLj z6}R?Ujod{|S(B>I9^5gpRYeK!OYU!clpH3DJnZ`_(6+eWF89~(D=DsLX{HgZJR z=3ztM;W?1z$1k~>gTORjfAbueT?`WTwZrOs*UY_n>$B~hzr)uFn|xuTavXN4xN?1M ze`pt#NmLJbG}_W_M$48`E1fF0jyJp->yCNq6;RECsKjsPj4kqM&U1mg9aZzjjE<^o zt-QquX8jC>o1u8+zcX<`m!Ve}Zf&Am{suUa^N1i9WTv;=iRDr7{Rdn_ zRpc^s=I7SgYpB+bMGij(A?*DGQ<6Bnfa8di+l_9A;$1zci(b9~+-Xw)VV8DjyHWF( zv_<3+orOxYa_++>wk|nRs>cX@f~vwk$TiJ){|Y1&Z$F0?q=|ELO^>~(zZaDSeY!Jh zCEH`CIrlXFhFM#av_Ohg=L3xrfIW#i7Nu>3}pH^PYBfY*(;Im0~jbv%MKJ#LHUy z$>iIcot`Ek_LA|lKWa9rkXM_Cq2;>1x5d^>>8LC;{?Mie2z_)_w~Uust5f0Xbqo7O z;mRKSe3UEXB8s?@##`wqITx6!6N6VC@%yyHK#~E|03Q7rG3&nf{Pe&`cGxgBN17p-aT~F9W=m{m0_ev@sp1ck~=!i}?#Z}mr|NNrQ3Q(c; z3IJz!)euVH74{HX3&5Z; LevoConfig: + """ + Retrieves the Levo configuration based on environment variables. + + Returns: + LevoConfig: Configuration object containing Levo OTLP settings. + + Raises: + ValueError: If required environment variables are missing. + """ + # Required environment variables + api_key = os.environ.get("LEVOAI_API_KEY", None) + org_id = os.environ.get("LEVOAI_ORG_ID", None) + workspace_id = os.environ.get("LEVOAI_WORKSPACE_ID", None) + collector_url = os.environ.get("LEVOAI_COLLECTOR_URL", None) + + # Validate required env vars + if not api_key: + raise ValueError( + "LEVOAI_API_KEY environment variable is required for Levo integration." + ) + if not org_id: + raise ValueError( + "LEVOAI_ORG_ID environment variable is required for Levo integration." + ) + if not workspace_id: + raise ValueError( + "LEVOAI_WORKSPACE_ID environment variable is required for Levo integration." + ) + if not collector_url: + raise ValueError( + "LEVOAI_COLLECTOR_URL environment variable is required for Levo integration. " + "Please contact Levo support to get your collector URL." + ) + + # Use collector URL exactly as provided by the user + endpoint = collector_url + protocol: Protocol = "otlp_http" + + # Build OTLP headers string + # Format: Authorization=Bearer {api_key},x-levo-organization-id={org_id},x-levo-workspace-id={workspace_id} + headers_parts = [f"Authorization=Bearer {api_key}"] + headers_parts.append(f"x-levo-organization-id={org_id}") + headers_parts.append(f"x-levo-workspace-id={workspace_id}") + + otlp_auth_headers = ",".join(headers_parts) + + return LevoConfig( + otlp_auth_headers=otlp_auth_headers, + protocol=protocol, + endpoint=endpoint, + ) + + async def async_health_check(self): + """ + Health check for Levo integration. + + Returns: + dict: Health status with status and message/error_message keys. + """ + try: + config = self.get_levo_config() + + if not config.otlp_auth_headers: + return { + "status": "unhealthy", + "error_message": "LEVOAI_API_KEY environment variable not set", + } + + return { + "status": "healthy", + "message": "Levo credentials are configured properly", + } + except ValueError as e: + return { + "status": "unhealthy", + "error_message": str(e), + } + diff --git a/litellm/litellm_core_utils/custom_logger_registry.py b/litellm/litellm_core_utils/custom_logger_registry.py index fa2ff42e1d..47cbcb8aec 100644 --- a/litellm/litellm_core_utils/custom_logger_registry.py +++ b/litellm/litellm_core_utils/custom_logger_registry.py @@ -76,6 +76,7 @@ class CustomLoggerRegistry: "arize_phoenix": OpenTelemetry, "langtrace": OpenTelemetry, "weave_otel": OpenTelemetry, + "levo": OpenTelemetry, "mlflow": MlflowLogger, "langfuse": LangfusePromptManagement, "otel": OpenTelemetry, diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 5c3c56e412..cd32493556 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -3699,6 +3699,31 @@ def _init_custom_logger_compatible_class( # noqa: PLR0915 ) _in_memory_loggers.append(_arize_phoenix_otel_logger) return _arize_phoenix_otel_logger # type: ignore + elif logging_integration == "levo": + from litellm.integrations.levo.levo import LevoLogger + from litellm.integrations.opentelemetry import ( + OpenTelemetry, + OpenTelemetryConfig, + ) + + levo_config = LevoLogger.get_levo_config() + otel_config = OpenTelemetryConfig( + exporter=levo_config.protocol, + endpoint=levo_config.endpoint, + headers=levo_config.otlp_auth_headers, + ) + + # Check if LevoLogger instance already exists + for callback in _in_memory_loggers: + if ( + isinstance(callback, LevoLogger) + and callback.callback_name == "levo" + ): + return callback # type: ignore + + _levo_otel_logger = LevoLogger(config=otel_config, callback_name="levo") + _in_memory_loggers.append(_levo_otel_logger) + return _levo_otel_logger # type: ignore elif logging_integration == "otel": from litellm.integrations.opentelemetry import OpenTelemetry diff --git a/tests/test_litellm/integrations/levo/__init__.py b/tests/test_litellm/integrations/levo/__init__.py new file mode 100644 index 0000000000..1560e78b7b --- /dev/null +++ b/tests/test_litellm/integrations/levo/__init__.py @@ -0,0 +1 @@ +# Levo integration tests diff --git a/tests/test_litellm/integrations/levo/test_levo.py b/tests/test_litellm/integrations/levo/test_levo.py new file mode 100644 index 0000000000..5d042cbc06 --- /dev/null +++ b/tests/test_litellm/integrations/levo/test_levo.py @@ -0,0 +1,407 @@ +import unittest +from unittest.mock import patch + +import pytest + +from litellm.integrations.levo.levo import LevoConfig, LevoLogger +from litellm.integrations.opentelemetry import OpenTelemetryConfig + +# Try to import OpenTelemetry packages, skip tests if not available +try: + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, + ) + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + + OPENTELEMETRY_AVAILABLE = True +except ImportError: + OPENTELEMETRY_AVAILABLE = False + + +class TestLevoConfig(unittest.TestCase): + """Unit tests for LevoLogger configuration.""" + + @patch.dict( + "os.environ", + { + "LEVOAI_API_KEY": "test-api-key", + "LEVOAI_ORG_ID": "test-org-id", + "LEVOAI_WORKSPACE_ID": "test-workspace-id", + "LEVOAI_COLLECTOR_URL": "https://collector.levo.ai", + }, + ) + def test_get_levo_config_with_all_required_vars(self): + """Test get_levo_config() with all required environment variables.""" + config = LevoLogger.get_levo_config() + + # Verify headers include all three values + self.assertIn("Authorization=Bearer test-api-key", config.otlp_auth_headers) + self.assertIn("x-levo-organization-id=test-org-id", config.otlp_auth_headers) + self.assertIn("x-levo-workspace-id=test-workspace-id", config.otlp_auth_headers) + + # Verify endpoint uses provided collector URL exactly as-is + self.assertEqual(config.endpoint, "https://collector.levo.ai") + + # Verify protocol is otlp_http + self.assertEqual(config.protocol, "otlp_http") + + @patch.dict( + "os.environ", + { + "LEVOAI_API_KEY": "test-api-key", + "LEVOAI_ORG_ID": "test-org-id", + "LEVOAI_WORKSPACE_ID": "test-workspace-id", + "LEVOAI_COLLECTOR_URL": "https://custom.collector.com", + }, + ) + def test_get_levo_config_with_custom_collector_url(self): + """Test get_levo_config() with custom collector URL.""" + config = LevoLogger.get_levo_config() + + # Verify endpoint uses custom URL exactly as provided + self.assertEqual(config.endpoint, "https://custom.collector.com") + self.assertEqual(config.protocol, "otlp_http") + + @patch.dict("os.environ", {}, clear=True) + def test_get_levo_config_missing_api_key(self): + """Test get_levo_config() raises ValueError when LEVOAI_API_KEY is missing.""" + with pytest.raises(ValueError, match="LEVOAI_API_KEY"): + LevoLogger.get_levo_config() + + @patch.dict( + "os.environ", + { + "LEVOAI_API_KEY": "test-api-key", + }, + clear=True, + ) + def test_get_levo_config_missing_org_id(self): + """Test get_levo_config() raises ValueError when LEVOAI_ORG_ID is missing.""" + with pytest.raises(ValueError, match="LEVOAI_ORG_ID"): + LevoLogger.get_levo_config() + + @patch.dict( + "os.environ", + { + "LEVOAI_API_KEY": "test-api-key", + "LEVOAI_ORG_ID": "test-org-id", + }, + clear=True, + ) + def test_get_levo_config_missing_workspace_id(self): + """Test get_levo_config() raises ValueError when LEVOAI_WORKSPACE_ID is missing.""" + with pytest.raises(ValueError, match="LEVOAI_WORKSPACE_ID"): + LevoLogger.get_levo_config() + + @patch.dict( + "os.environ", + { + "LEVOAI_API_KEY": "test-api-key", + "LEVOAI_ORG_ID": "test-org-id", + "LEVOAI_WORKSPACE_ID": "test-workspace-id", + }, + clear=True, + ) + def test_get_levo_config_missing_collector_url(self): + """Test get_levo_config() raises ValueError when LEVOAI_COLLECTOR_URL is missing.""" + with pytest.raises(ValueError, match="LEVOAI_COLLECTOR_URL"): + LevoLogger.get_levo_config() + + @patch.dict( + "os.environ", + { + "LEVOAI_API_KEY": "test-api-key", + "LEVOAI_ORG_ID": "test-org-id", + "LEVOAI_WORKSPACE_ID": "test-workspace-id", + "LEVOAI_COLLECTOR_URL": "http://localhost:4318", + }, + ) + def test_get_levo_config_with_http_endpoint(self): + """Test get_levo_config() with HTTP endpoint.""" + config = LevoLogger.get_levo_config() + + # Should use HTTP endpoint exactly as provided + self.assertEqual(config.endpoint, "http://localhost:4318") + self.assertEqual(config.protocol, "otlp_http") + + @patch.dict( + "os.environ", + { + "LEVOAI_API_KEY": "test-api-key", + "LEVOAI_ORG_ID": "test-org-id", + "LEVOAI_WORKSPACE_ID": "test-workspace-id", + "LEVOAI_COLLECTOR_URL": "https://collector.levo.ai", + }, + ) + def test_levo_config_headers_format(self): + """Test that OTLP headers are formatted correctly.""" + config = LevoLogger.get_levo_config() + + # Verify headers contain all required parts + self.assertIn("Authorization=Bearer test-api-key", config.otlp_auth_headers) + self.assertIn("x-levo-organization-id=test-org-id", config.otlp_auth_headers) + self.assertIn("x-levo-workspace-id=test-workspace-id", config.otlp_auth_headers) + + # Verify headers are comma-separated + header_parts = config.otlp_auth_headers.split(",") + self.assertEqual(len(header_parts), 3) + + +class TestLevoIntegration(unittest.TestCase): + """Integration tests for LevoLogger.""" + + @patch.dict( + "os.environ", + { + "LEVOAI_API_KEY": "test-api-key", + "LEVOAI_ORG_ID": "test-org-id", + "LEVOAI_WORKSPACE_ID": "test-workspace-id", + "LEVOAI_COLLECTOR_URL": "https://collector.levo.ai", + }, + ) + @pytest.mark.skipif( + not OPENTELEMETRY_AVAILABLE, reason="OpenTelemetry packages not installed" + ) + @patch( + "litellm.integrations.opentelemetry.OpenTelemetry._init_otel_logger_on_litellm_proxy" + ) + def test_levo_logger_instantiation(self, mock_init_proxy): + """Test that LevoLogger can be instantiated with proper config.""" + # Mock the proxy initialization to avoid importing proxy code + mock_init_proxy.return_value = None + + config = LevoLogger.get_levo_config() + otel_config = OpenTelemetryConfig( + exporter=config.protocol, + endpoint=config.endpoint, + headers=config.otlp_auth_headers, + ) + + # Create a tracer provider with in-memory exporter to avoid requiring OTLP packages + tracer_provider = TracerProvider() + tracer_provider.add_span_processor(SimpleSpanProcessor(InMemorySpanExporter())) + + # Create LevoLogger instance with mocked tracer provider + levo_logger = LevoLogger( + config=otel_config, callback_name="levo", tracer_provider=tracer_provider + ) + + # Verify it's an instance of OpenTelemetry + self.assertIsInstance(levo_logger, LevoLogger) + # Check it extends OpenTelemetry by checking base classes + from litellm.integrations.opentelemetry import OpenTelemetry + + self.assertIsInstance(levo_logger, OpenTelemetry) + + # Verify callback_name is set + self.assertEqual(levo_logger.callback_name, "levo") + + @patch.dict( + "os.environ", + { + "LEVOAI_API_KEY": "test-api-key", + "LEVOAI_ORG_ID": "test-org-id", + "LEVOAI_WORKSPACE_ID": "test-workspace-id", + "LEVOAI_COLLECTOR_URL": "https://collector.levo.ai", + }, + ) + @pytest.mark.skipif( + not OPENTELEMETRY_AVAILABLE, reason="OpenTelemetry packages not installed" + ) + @patch( + "litellm.integrations.opentelemetry.OpenTelemetry._init_otel_logger_on_litellm_proxy" + ) + @pytest.mark.asyncio + async def test_levo_logger_health_check_healthy(self, mock_init_proxy): + """Test health check returns healthy status when config is valid.""" + # Mock the proxy initialization to avoid importing proxy code + mock_init_proxy.return_value = None + + config = LevoLogger.get_levo_config() + otel_config = OpenTelemetryConfig( + exporter=config.protocol, + endpoint=config.endpoint, + headers=config.otlp_auth_headers, + ) + + # Create tracer provider with in-memory exporter + tracer_provider = TracerProvider() + tracer_provider.add_span_processor(SimpleSpanProcessor(InMemorySpanExporter())) + + levo_logger = LevoLogger( + config=otel_config, callback_name="levo", tracer_provider=tracer_provider + ) + + # Run health check + result = await levo_logger.async_health_check() + + self.assertEqual(result["status"], "healthy") + self.assertIn("message", result) + + @patch.dict("os.environ", {}, clear=True) + def test_levo_logger_health_check_unhealthy(self): + """Test health check returns unhealthy status when required vars are missing.""" + # Try to create logger without required env vars + # This should fail during config, but we can test health check logic + with pytest.raises(ValueError): + LevoLogger.get_levo_config() + + @patch.dict( + "os.environ", + { + "LEVOAI_API_KEY": "test-api-key", + "LEVOAI_ORG_ID": "test-org-id", + "LEVOAI_WORKSPACE_ID": "test-workspace-id", + "LEVOAI_COLLECTOR_URL": "https://collector.levo.ai", + }, + ) + @pytest.mark.skipif( + not OPENTELEMETRY_AVAILABLE, reason="OpenTelemetry packages not installed" + ) + @patch( + "litellm.integrations.opentelemetry.OpenTelemetry._init_otel_logger_on_litellm_proxy" + ) + def test_levo_logger_callback_name(self, mock_init_proxy): + """Test that callback_name is properly set and used.""" + # Mock the proxy initialization to avoid importing proxy code + mock_init_proxy.return_value = None + + config = LevoLogger.get_levo_config() + otel_config = OpenTelemetryConfig( + exporter=config.protocol, + endpoint=config.endpoint, + headers=config.otlp_auth_headers, + ) + + # Create tracer provider with in-memory exporter + tracer_provider = TracerProvider() + tracer_provider.add_span_processor(SimpleSpanProcessor(InMemorySpanExporter())) + + levo_logger = LevoLogger( + config=otel_config, callback_name="levo", tracer_provider=tracer_provider + ) + + # Verify callback_name attribute + self.assertEqual(levo_logger.callback_name, "levo") + + +@pytest.mark.parametrize( + "env_vars, expected_headers_contains, expected_endpoint, expected_protocol", + [ + pytest.param( + { + "LEVOAI_API_KEY": "test-key", + "LEVOAI_ORG_ID": "test-org", + "LEVOAI_WORKSPACE_ID": "test-workspace", + "LEVOAI_COLLECTOR_URL": "https://collector.levo.ai", + }, + [ + "Authorization=Bearer test-key", + "x-levo-organization-id=test-org", + "x-levo-workspace-id=test-workspace", + ], + "https://collector.levo.ai", + "otlp_http", + id="collector URL with all required vars", + ), + pytest.param( + { + "LEVOAI_API_KEY": "key-123", + "LEVOAI_ORG_ID": "org-456", + "LEVOAI_WORKSPACE_ID": "workspace-789", + "LEVOAI_COLLECTOR_URL": "https://custom.example.com", + }, + [ + "Authorization=Bearer key-123", + "x-levo-organization-id=org-456", + "x-levo-workspace-id=workspace-789", + ], + "https://custom.example.com", + "otlp_http", + id="custom collector URL", + ), + pytest.param( + { + "LEVOAI_API_KEY": "key-123", + "LEVOAI_ORG_ID": "org-456", + "LEVOAI_WORKSPACE_ID": "workspace-789", + "LEVOAI_COLLECTOR_URL": "http://localhost:9999", + }, + ["Authorization=Bearer key-123"], + "http://localhost:9999", + "otlp_http", + id="custom HTTP endpoint", + ), + ], +) +def test_get_levo_config_parametrized( + monkeypatch, + env_vars, + expected_headers_contains, + expected_endpoint, + expected_protocol, +): + """Parametrized tests for get_levo_config() with various configurations.""" + # Clear all Levo-related env vars first to ensure clean state + for key in [ + "LEVOAI_API_KEY", + "LEVOAI_ORG_ID", + "LEVOAI_WORKSPACE_ID", + "LEVOAI_COLLECTOR_URL", + "LEVOAI_ENV_NAME", + ]: + monkeypatch.delenv(key, raising=False) + + for key, value in env_vars.items(): + monkeypatch.setenv(key, value) + + config = LevoLogger.get_levo_config() + + assert isinstance(config, LevoConfig) + assert config.endpoint == expected_endpoint + assert config.protocol == expected_protocol + + # Verify all expected header parts are present + for header_part in expected_headers_contains: + assert header_part in config.otlp_auth_headers + + +@pytest.mark.parametrize( + "missing_var", + [ + pytest.param("LEVOAI_API_KEY", id="missing API key"), + pytest.param("LEVOAI_ORG_ID", id="missing org ID"), + pytest.param("LEVOAI_WORKSPACE_ID", id="missing workspace ID"), + pytest.param("LEVOAI_COLLECTOR_URL", id="missing collector URL"), + ], +) +def test_get_levo_config_missing_required_vars(monkeypatch, missing_var): + """Test that missing required environment variables raise ValueError.""" + # Clear all Levo-related env vars + for key in [ + "LEVOAI_API_KEY", + "LEVOAI_ORG_ID", + "LEVOAI_WORKSPACE_ID", + "LEVOAI_COLLECTOR_URL", + ]: + monkeypatch.delenv(key, raising=False) + + # Set all required vars except the missing one + required_vars = { + "LEVOAI_API_KEY": "test-key", + "LEVOAI_ORG_ID": "test-org", + "LEVOAI_WORKSPACE_ID": "test-workspace", + "LEVOAI_COLLECTOR_URL": "https://collector.levo.ai", + } + required_vars.pop(missing_var) + + for key, value in required_vars.items(): + monkeypatch.setenv(key, value) + + with pytest.raises(ValueError, match=missing_var): + LevoLogger.get_levo_config() + + +if __name__ == "__main__": + unittest.main()