feat: Add Levo AI integration (#18529)

This commit is contained in:
amangupta-20
2026-01-04 21:19:21 -06:00
committed by GitHub
parent 359b8df8b2
commit 399579f8ea
13 changed files with 874 additions and 1 deletions
@@ -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
<div className="levo-logo-container" style={{ marginTop: '0.5rem', marginBottom: '1rem' }}>
<div className="levo-logo-light">
<Image img={require('../../img/levo_logo.png')} />
</div>
<div className="levo-logo-dark">
<Image img={require('../../img/levo_logo_dark.png')} />
</div>
</div>
[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="<your-levo-api-key>"
export LEVOAI_ORG_ID="<your-levo-org-id>"
export LEVOAI_WORKSPACE_ID="<your-workspace-id>"
export LEVOAI_COLLECTOR_URL="<your-levo-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 <your-collector-url>/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).
@@ -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.
<Image img={require('../../img/traceloop_dash.png')} />
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+31
View File
@@ -28,3 +28,34 @@
--ifm-color-primary-lightest: #4fddbf;
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3);
}
/* Levo logo sizing and theme switching */
.levo-logo-container {
position: relative;
}
.levo-logo-container img,
.levo-logo-container picture,
.levo-logo-container .ideal-image {
max-width: 200px !important;
width: 200px !important;
height: auto !important;
}
/* Show light logo by default, hide dark logo */
.levo-logo-dark {
display: none !important;
}
.levo-logo-light {
display: block !important;
}
/* In dark mode, hide light logo and show dark logo */
[data-theme='dark'] .levo-logo-light {
display: none !important;
}
[data-theme='dark'] .levo-logo-dark {
display: block !important;
}
+1
View File
@@ -136,6 +136,7 @@ _custom_logger_compatible_callbacks_literal = Literal[
"gitlab",
"cloudzero",
"posthog",
"levo",
]
cold_storage_custom_logger: Optional[_custom_logger_compatible_callbacks_literal] = None
logged_real_time_event_types: Optional[Union[List[str], Literal["*"]]] = None
+125
View File
@@ -0,0 +1,125 @@
# Levo AI Integration
This integration enables sending LLM observability data to Levo AI using OpenTelemetry (OTLP) protocol.
## Overview
The Levo integration extends LiteLLM's OpenTelemetry support to automatically send traces to Levo's collector endpoint with proper authentication and routing headers.
## Features
- **Automatic OTLP Export**: Sends OpenTelemetry traces to Levo collector
- **Levo-Specific Headers**: Automatically includes `x-levo-organization-id` and `x-levo-workspace-id` for routing
- **Simple Configuration**: Just use `callbacks: ["levo"]` in your LiteLLM config
- **Environment-Based Setup**: Configure via environment variables
## Quick Start
### 1. Install Dependencies
```bash
pip install opentelemetry-api opentelemetry-sdk opentelemetry-exporter-otlp-proto-http opentelemetry-exporter-otlp-proto-grpc
```
### 2. Configure LiteLLM
Add to your `litellm_config.yaml`:
```yaml
litellm_settings:
callbacks: ["levo"]
```
### 3. Set Environment Variables
```bash
export LEVOAI_API_KEY="<your-levo-api-key>"
export LEVOAI_ORG_ID="<your-levo-org-id>"
export LEVOAI_WORKSPACE_ID="<your-workspace-id>"
export LEVOAI_COLLECTOR_URL="<your-levo-collector-url>"
```
### 4. Start LiteLLM
```bash
litellm --config config.yaml
```
All LLM requests will now automatically be sent to Levo!
## Configuration
### Required Environment Variables
| Variable | Description |
|----------|-------------|
| `LEVOAI_API_KEY` | Your Levo API key for authentication |
| `LEVOAI_ORG_ID` | Your Levo organization ID for routing |
| `LEVOAI_WORKSPACE_ID` | Your Levo workspace ID for routing |
| `LEVOAI_COLLECTOR_URL` | Full collector endpoint URL from Levo support |
### Optional Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `LEVOAI_ENV_NAME` | Environment name for tagging traces | `None` |
**Important**: The `LEVOAI_COLLECTOR_URL` is used exactly as provided. No path manipulation is performed.
## How It Works
1. **LevoLogger** extends LiteLLM's `OpenTelemetry` class
2. **Configuration** is read from environment variables via `get_levo_config()`
3. **OTLP Headers** are automatically set:
- `Authorization: Bearer {LEVOAI_API_KEY}`
- `x-levo-organization-id: {LEVOAI_ORG_ID}`
- `x-levo-workspace-id: {LEVOAI_WORKSPACE_ID}`
4. **Traces** are sent to the collector endpoint in OTLP format
## Code Structure
```
litellm/integrations/levo/
├── __init__.py # Exports LevoLogger
├── levo.py # LevoLogger implementation
└── README.md # This file
```
### Key Classes
- **LevoLogger**: Extends `OpenTelemetry`, handles Levo-specific configuration
- **LevoConfig**: Pydantic model for Levo configuration (defined in `levo.py`)
## Testing
See the test files in `tests/test_litellm/integrations/levo/`:
- `test_levo.py`: Unit tests for configuration
- `test_levo_integration.py`: Integration tests for callback registration
## Error Handling
The integration validates all required environment variables at initialization:
- Missing `LEVOAI_API_KEY`: Raises `ValueError` with clear message
- Missing `LEVOAI_ORG_ID`: Raises `ValueError` with clear message
- Missing `LEVOAI_WORKSPACE_ID`: Raises `ValueError` with clear message
- Missing `LEVOAI_COLLECTOR_URL`: Raises `ValueError` with clear message
## Integration with LiteLLM
The Levo callback is registered in:
- `litellm/litellm_core_utils/custom_logger_registry.py`: Maps `"levo"` to `LevoLogger`
- `litellm/litellm_core_utils/litellm_logging.py`: Instantiates `LevoLogger` when `callbacks: ["levo"]` is used
- `litellm/__init__.py`: Added to `_custom_logger_compatible_callbacks_literal`
## Documentation
For detailed documentation, see:
- [LiteLLM Levo Integration Docs](../../../../docs/my-website/docs/observability/levo_integration.md)
- [Levo Documentation](https://docs.levo.ai)
## Support
For issues or questions:
- LiteLLM Issues: https://github.com/BerriAI/litellm/issues
- Levo Support: support@levo.ai
+3
View File
@@ -0,0 +1,3 @@
from litellm.integrations.levo.levo import LevoLogger
__all__ = ["LevoLogger"]
+117
View File
@@ -0,0 +1,117 @@
import os
from typing import TYPE_CHECKING, Any, Optional, Union
from litellm.integrations.opentelemetry import OpenTelemetry
if TYPE_CHECKING:
from opentelemetry.trace import Span as _Span
from litellm.integrations.opentelemetry import OpenTelemetryConfig as _OpenTelemetryConfig
from litellm.types.integrations.arize import Protocol as _Protocol
Protocol = _Protocol
OpenTelemetryConfig = _OpenTelemetryConfig
Span = Union[_Span, Any]
else:
Protocol = Any
OpenTelemetryConfig = Any
Span = Any
class LevoConfig:
"""Configuration for Levo OTLP integration."""
def __init__(
self,
otlp_auth_headers: Optional[str],
protocol: Protocol,
endpoint: str,
):
self.otlp_auth_headers = otlp_auth_headers
self.protocol = protocol
self.endpoint = endpoint
class LevoLogger(OpenTelemetry):
"""Levo Logger that extends OpenTelemetry for OTLP integration."""
@staticmethod
def get_levo_config() -> 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),
}
@@ -76,6 +76,7 @@ class CustomLoggerRegistry:
"arize_phoenix": OpenTelemetry,
"langtrace": OpenTelemetry,
"weave_otel": OpenTelemetry,
"levo": OpenTelemetry,
"mlflow": MlflowLogger,
"langfuse": LangfusePromptManagement,
"otel": OpenTelemetry,
@@ -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
@@ -0,0 +1 @@
# Levo integration tests
@@ -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()