mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-11 14:22:48 +00:00
Merge branch 'main' into fix/gemini-imagen-model-name-validation
This commit is contained in:
@@ -1291,6 +1291,7 @@ jobs:
|
||||
pip install jinja2
|
||||
pip install "tokenizers==0.20.0"
|
||||
pip install "uvloop==0.21.0"
|
||||
pip install "fastuuid==0.12.0"
|
||||
pip install jsonschema
|
||||
- setup_litellm_enterprise_pip
|
||||
- run:
|
||||
|
||||
@@ -14,4 +14,5 @@ google-cloud-iam==2.19.1
|
||||
fastapi-sso==0.16.0
|
||||
uvloop==0.21.0
|
||||
mcp==1.10.1 # for MCP server
|
||||
semantic_router==0.1.10 # for auto-routing with litellm
|
||||
semantic_router==0.1.10 # for auto-routing with litellm
|
||||
fastuuid==0.12.0
|
||||
@@ -10,3 +10,4 @@ tests
|
||||
*.tgz
|
||||
log.txt
|
||||
docker/Dockerfile.*
|
||||
*.whl
|
||||
|
||||
+2
-1
@@ -95,4 +95,5 @@ test.py
|
||||
litellm_config.yaml
|
||||
.cursor
|
||||
.vscode/launch.json
|
||||
litellm/proxy/to_delete_loadtest_work/*
|
||||
*.whl
|
||||
litellm/proxy/to_delete_loadtest_work/*
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
# CometAPI
|
||||
LiteLLM supports all AI models from [CometAPI](https://www.cometapi.com/). CometAPI provides access to 500+ AI models through a unified API interface, including cutting-edge models like GPT-5, Claude Opus 4.1, and various other state-of-the-art language models.
|
||||
|
||||
## Authentication
|
||||
|
||||
To use CometAPI models, you need to obtain an API key from [CometAPI Token Console](https://api.cometapi.com/console/token). CometAPI offers free tokens for new users - you can get your free API key instantly by registering.
|
||||
|
||||
## Usage
|
||||
|
||||
Set your CometAPI key as an environment variable and use the completion function:
|
||||
|
||||
```python
|
||||
import os
|
||||
from litellm import completion
|
||||
|
||||
# Set API key
|
||||
os.environ["COMETAPI_KEY"] = "your_comet_api_key_here"
|
||||
|
||||
# Define messages
|
||||
messages = [{"content": "Hello, how are you?", "role": "user"}]
|
||||
|
||||
# Method 1: Using environment variable (recommended)
|
||||
response = completion(
|
||||
model="cometapi/gpt-5",
|
||||
messages=messages
|
||||
)
|
||||
|
||||
print(response.choices[0].message.content)
|
||||
```
|
||||
|
||||
### Alternative Usage - Explicit API Key
|
||||
|
||||
You can also pass the API key explicitly:
|
||||
|
||||
```python
|
||||
import os
|
||||
from litellm import completion
|
||||
|
||||
# Define messages
|
||||
messages = [{"content": "Hello, how are you?", "role": "user"}]
|
||||
|
||||
# Method 2: Explicitly passing API key
|
||||
response = completion(
|
||||
model="cometapi/gpt-4o",
|
||||
messages=messages,
|
||||
api_key="your_comet_api_key_here"
|
||||
)
|
||||
|
||||
print(response.choices[0].message.content)
|
||||
```
|
||||
|
||||
## Usage - Streaming
|
||||
|
||||
Just set `stream=True` when calling completion:
|
||||
|
||||
```python
|
||||
import os
|
||||
from litellm import completion
|
||||
|
||||
os.environ["COMETAPI_KEY"] = "your_comet_api_key_here"
|
||||
|
||||
messages = [{"content": "Hello, how are you?", "role": "user"}]
|
||||
|
||||
response = completion(
|
||||
model="cometapi/gpt-5",
|
||||
messages=messages,
|
||||
stream=True
|
||||
)
|
||||
|
||||
for chunk in response:
|
||||
print(chunk.choices[0].delta.content or "", end="")
|
||||
```
|
||||
|
||||
## Usage - Async Streaming
|
||||
|
||||
For async streaming, use `acompletion`:
|
||||
|
||||
```python
|
||||
from litellm import acompletion
|
||||
import asyncio, os, traceback
|
||||
|
||||
async def completion_call():
|
||||
try:
|
||||
os.environ["COMETAPI_KEY"] = "your_comet_api_key_here"
|
||||
|
||||
print("test acompletion + streaming")
|
||||
response = await acompletion(
|
||||
model="cometapi/chatgpt-4o-latest",
|
||||
messages=[{"content": "Hello, how are you?", "role": "user"}],
|
||||
stream=True
|
||||
)
|
||||
print(f"response: {response}")
|
||||
async for chunk in response:
|
||||
print(chunk)
|
||||
except:
|
||||
print(f"error occurred: {traceback.format_exc()}")
|
||||
pass
|
||||
|
||||
# Run the async function
|
||||
await completion_call()
|
||||
```
|
||||
|
||||
## CometAPI Models
|
||||
|
||||
CometAPI offers access to 500+ AI models through a unified API. Some popular models include:
|
||||
|
||||
| Model Name | Function Call |
|
||||
|------------|---------------|
|
||||
| cometapi/gpt-5 | `completion('cometapi/gpt-5', messages)` |
|
||||
| cometapi/gpt-5-mini | `completion('cometapi/gpt-5-mini', messages)` |
|
||||
| cometapi/gpt-5-nano | `completion('cometapi/gpt-5-nano', messages)` |
|
||||
| cometapi/gpt-oss-20b | `completion('cometapi/gpt-oss-20b', messages)` |
|
||||
| cometapi/gpt-oss-120b | `completion('cometapi/gpt-oss-120b', messages)` |
|
||||
| cometapi/chatgpt-4o-latest | `completion('cometapi/chatgpt-4o-latest', messages)` |
|
||||
|
||||
For a complete list of available models, visit the [CometAPI Models page](https://www.cometapi.com/model/).
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Required |
|
||||
|----------|-------------|----------|
|
||||
| `COMETAPI_KEY` | Your CometAPI API key | Yes |
|
||||
|
||||
## Error Handling
|
||||
|
||||
```python
|
||||
import os
|
||||
from litellm import completion
|
||||
|
||||
try:
|
||||
os.environ["COMETAPI_KEY"] = "your_comet_api_key_here"
|
||||
|
||||
messages = [{"content": "Hello, how are you?", "role": "user"}]
|
||||
|
||||
response = completion(
|
||||
model="cometapi/gpt-5",
|
||||
messages=messages
|
||||
)
|
||||
|
||||
print(response.choices[0].message.content)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
```
|
||||
@@ -0,0 +1,299 @@
|
||||
import Image from '@theme/IdealImage';
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
|
||||
# Noma Security
|
||||
|
||||
Use [Noma Security](https://noma.security/) to protect your LLM applications with comprehensive AI content moderation and safety guardrails.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Define Guardrails on your LiteLLM config.yaml
|
||||
|
||||
Define your guardrails under the `guardrails` section:
|
||||
|
||||
```yaml showLineNumbers title="litellm config.yaml"
|
||||
model_list:
|
||||
- model_name: gpt-4o-mini
|
||||
litellm_params:
|
||||
model: openai/gpt-4o-mini
|
||||
api_key: os.environ/OPENAI_API_KEY
|
||||
|
||||
guardrails:
|
||||
- guardrail_name: "noma-guard"
|
||||
litellm_params:
|
||||
guardrail: noma
|
||||
mode: "during_call"
|
||||
api_key: os.environ/NOMA_API_KEY
|
||||
api_base: os.environ/NOMA_API_BASE
|
||||
- guardrail_name: "noma-pre-guard"
|
||||
litellm_params:
|
||||
guardrail: noma
|
||||
mode: "pre_call"
|
||||
api_key: os.environ/NOMA_API_KEY
|
||||
api_base: os.environ/NOMA_API_BASE
|
||||
```
|
||||
|
||||
#### Supported values for `mode`
|
||||
|
||||
- `pre_call` Run **before** LLM call, on **input**
|
||||
- `post_call` Run **after** LLM call, on **input & output**
|
||||
- `during_call` Run **during** LLM call, on **input**. Same as `pre_call` but runs in parallel with the LLM call. Response not returned until guardrail check completes
|
||||
|
||||
### 2. Start LiteLLM Gateway
|
||||
|
||||
```shell
|
||||
litellm --config config.yaml --detailed_debug
|
||||
```
|
||||
|
||||
### 3. Test request
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="Unsuccessful call" value="not-allowed">
|
||||
|
||||
Expect this to fail since the request contains harmful content:
|
||||
|
||||
```shell showLineNumbers title="Curl Request"
|
||||
curl -i http://0.0.0.0:4000/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "gpt-4o-mini",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Tell me how to hack into someone's email account"}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
Expected response on failure:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"message": "{\n \"error\": \"Request blocked by Noma guardrail\",\n \"details\": {\n \"prompt\": {\n \"harmfulContent\": {\n \"result\": true,\n \"confidence\": 0.95\n }\n }\n }\n }",
|
||||
"type": "None",
|
||||
"param": "None",
|
||||
"code": "400"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
|
||||
<TabItem label="Successful Call" value="allowed">
|
||||
|
||||
```shell showLineNumbers title="Curl Request"
|
||||
curl -i http://0.0.0.0:4000/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "gpt-4o-mini",
|
||||
"messages": [
|
||||
{"role": "user", "content": "What is the capital of France?"}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
Expected response:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "chatcmpl-123",
|
||||
"object": "chat.completion",
|
||||
"created": 1677652288,
|
||||
"model": "gpt-4o-mini",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "The capital of France is Paris."
|
||||
},
|
||||
"finish_reason": "stop"
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 9,
|
||||
"completion_tokens": 12,
|
||||
"total_tokens": 21
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Supported Params
|
||||
|
||||
```yaml
|
||||
guardrails:
|
||||
- guardrail_name: "noma-guard"
|
||||
litellm_params:
|
||||
guardrail: noma
|
||||
mode: "pre_call"
|
||||
api_key: os.environ/NOMA_API_KEY
|
||||
api_base: os.environ/NOMA_API_BASE
|
||||
### OPTIONAL ###
|
||||
# application_id: "my-app"
|
||||
# monitor_mode: false
|
||||
# block_failures: true
|
||||
```
|
||||
|
||||
### Required Parameters
|
||||
|
||||
- **`api_key`**: Your Noma Security API key (set as `os.environ/NOMA_API_KEY` in YAML config)
|
||||
|
||||
### Optional Parameters
|
||||
|
||||
- **`api_base`**: Noma API base URL (defaults to `https://api.noma.security/`)
|
||||
- **`application_id`**: Your application identifier (defaults to `"litellm"`)
|
||||
- **`monitor_mode`**: If `true`, logs violations without blocking (defaults to `false`)
|
||||
- **`block_failures`**: If `true`, blocks requests when guardrail API failures occur (defaults to `true`)
|
||||
|
||||
## Environment Variables
|
||||
|
||||
You can set these environment variables instead of hardcoding values in your config:
|
||||
|
||||
```shell
|
||||
export NOMA_API_KEY="your-api-key-here"
|
||||
export NOMA_API_BASE="https://api.noma.security/" # Optional
|
||||
export NOMA_APPLICATION_ID="my-app" # Optional
|
||||
export NOMA_MONITOR_MODE="false" # Optional
|
||||
export NOMA_BLOCK_FAILURES="true" # Optional
|
||||
```
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Monitor Mode
|
||||
|
||||
Use monitor mode to test your guardrails without blocking requests:
|
||||
|
||||
```yaml
|
||||
guardrails:
|
||||
- guardrail_name: "noma-monitor"
|
||||
litellm_params:
|
||||
guardrail: noma
|
||||
mode: "pre_call"
|
||||
api_key: os.environ/NOMA_API_KEY
|
||||
monitor_mode: true # Log violations but don't block
|
||||
```
|
||||
|
||||
### Handling API Failures
|
||||
|
||||
Control behavior when the Noma API is unavailable:
|
||||
|
||||
```yaml
|
||||
guardrails:
|
||||
- guardrail_name: "noma-failopen"
|
||||
litellm_params:
|
||||
guardrail: noma
|
||||
mode: "pre_call"
|
||||
api_key: os.environ/NOMA_API_KEY
|
||||
block_failures: false # Allow requests to proceed if guardrail API fails
|
||||
```
|
||||
|
||||
### Multiple Guardrails
|
||||
|
||||
Apply different configurations for input and output:
|
||||
|
||||
```yaml
|
||||
guardrails:
|
||||
- guardrail_name: "noma-strict-input"
|
||||
litellm_params:
|
||||
guardrail: noma
|
||||
mode: "pre_call"
|
||||
api_key: os.environ/NOMA_API_KEY
|
||||
block_failures: true
|
||||
|
||||
- guardrail_name: "noma-monitor-output"
|
||||
litellm_params:
|
||||
guardrail: noma
|
||||
mode: "post_call"
|
||||
api_key: os.environ/NOMA_API_KEY
|
||||
monitor_mode: true
|
||||
```
|
||||
|
||||
## ✨ Pass Additional Parameters
|
||||
|
||||
Use `extra_body` to pass additional parameters to the Noma Security API call, such as dynamically setting the application ID for specific requests.
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="openai" label="OpenAI Python">
|
||||
|
||||
```python
|
||||
import openai
|
||||
client = openai.OpenAI(
|
||||
api_key="your-api-key",
|
||||
base_url="http://0.0.0.0:4000"
|
||||
)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-4o-mini",
|
||||
messages=[{"role": "user", "content": "Hello, how are you?"}],
|
||||
extra_body={
|
||||
"guardrails": {
|
||||
"noma-guard": {
|
||||
"extra_body": {
|
||||
"application_id": "my-specific-app-id"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
```
|
||||
</TabItem>
|
||||
|
||||
<TabItem value="curl" label="Curl">
|
||||
|
||||
```shell
|
||||
curl 'http://0.0.0.0:4000/v1/chat/completions' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"model": "gpt-4o-mini",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Hello, how are you?"
|
||||
}
|
||||
],
|
||||
"guardrails": {
|
||||
"noma-guard": {
|
||||
"extra_body": {
|
||||
"application_id": "my-specific-app-id"
|
||||
}
|
||||
}
|
||||
}
|
||||
}'
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
This allows you to override the default `application_id` parameter for specific requests, which is useful for tracking usage across different applications or components.
|
||||
|
||||
## Response Details
|
||||
|
||||
When content is blocked, Noma provides detailed information about the violations as JSON inside the `message` field, with the following structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Request blocked by Noma guardrail",
|
||||
"details": {
|
||||
"prompt": {
|
||||
"harmfulContent": {
|
||||
"result": true,
|
||||
"confidence": 0.95
|
||||
},
|
||||
"sensitiveData": {
|
||||
"email": {
|
||||
"result": true,
|
||||
"entities": ["user@example.com"]
|
||||
}
|
||||
},
|
||||
"bannedTopics": {
|
||||
"violence": {
|
||||
"result": true,
|
||||
"confidence": 0.88
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -40,6 +40,7 @@ const sidebars = {
|
||||
"proxy/guardrails/guardrails_ai",
|
||||
"proxy/guardrails/lakera_ai",
|
||||
"proxy/guardrails/model_armor",
|
||||
"proxy/guardrails/noma_security",
|
||||
"proxy/guardrails/openai_moderation",
|
||||
"proxy/guardrails/pangea",
|
||||
"proxy/guardrails/pillar_security",
|
||||
|
||||
+43
-6
@@ -4,21 +4,41 @@ import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from logging import Formatter
|
||||
|
||||
set_verbose = False
|
||||
|
||||
def __strtobool(val: str) -> bool:
|
||||
"""Convert a string representation of truth to true (1) or false (0).
|
||||
|
||||
True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values
|
||||
are 'n', 'no', 'f', 'false', 'off', and '0'. Raises ValueError if
|
||||
'val' is anything else.
|
||||
"""
|
||||
val = val.lower()
|
||||
if val in ('y', 'yes', 't', 'true', 'on', '1'):
|
||||
return True
|
||||
elif val in ('n', 'no', 'f', 'false', 'off', '0'):
|
||||
return False
|
||||
else:
|
||||
raise ValueError(f"invalid truth value {val!r}")
|
||||
|
||||
if set_verbose is True:
|
||||
logging.warning(
|
||||
"`litellm.set_verbose` is deprecated. Please set `os.environ['LITELLM_LOG'] = 'DEBUG'` for debug logs."
|
||||
)
|
||||
json_logs = bool(os.getenv("JSON_LOGS", False))
|
||||
|
||||
json_logs = __strtobool(os.getenv("JSON_LOGS", "False"))
|
||||
# Create a handler for the logger (you may need to adapt this based on your needs)
|
||||
log_level = os.getenv("LITELLM_LOG", "DEBUG")
|
||||
numeric_level: str = getattr(logging, log_level.upper())
|
||||
handler = logging.StreamHandler()
|
||||
handler.setLevel(numeric_level)
|
||||
|
||||
log_file = os.getenv("LITELLM_LOG_FILE", "")
|
||||
|
||||
file_handler = None
|
||||
if log_file:
|
||||
file_handler = logging.FileHandler(log_file)
|
||||
file_handler.setLevel(numeric_level)
|
||||
class JsonFormatter(Formatter):
|
||||
def __init__(self):
|
||||
super(JsonFormatter, self).__init__()
|
||||
@@ -40,6 +60,7 @@ class JsonFormatter(Formatter):
|
||||
|
||||
return json.dumps(json_record)
|
||||
|
||||
json_formatter = JsonFormatter()
|
||||
|
||||
# Function to set up exception handlers for JSON logging
|
||||
def _setup_json_exception_handlers(formatter):
|
||||
@@ -89,8 +110,10 @@ def _setup_json_exception_handlers(formatter):
|
||||
|
||||
# Create a formatter and set it for the handler
|
||||
if json_logs:
|
||||
handler.setFormatter(JsonFormatter())
|
||||
_setup_json_exception_handlers(JsonFormatter())
|
||||
handler.setFormatter(json_formatter)
|
||||
if file_handler:
|
||||
file_handler.setFormatter(json_formatter)
|
||||
_setup_json_exception_handlers(json_formatter)
|
||||
else:
|
||||
formatter = logging.Formatter(
|
||||
"\033[92m%(asctime)s - %(name)s:%(levelname)s\033[0m: %(filename)s:%(lineno)s - %(message)s",
|
||||
@@ -98,11 +121,18 @@ else:
|
||||
)
|
||||
|
||||
handler.setFormatter(formatter)
|
||||
if file_handler:
|
||||
file_handler.setFormatter(formatter)
|
||||
|
||||
verbose_proxy_logger = logging.getLogger("LiteLLM Proxy")
|
||||
verbose_router_logger = logging.getLogger("LiteLLM Router")
|
||||
verbose_logger = logging.getLogger("LiteLLM")
|
||||
|
||||
# Set logger levels
|
||||
verbose_proxy_logger.setLevel(numeric_level)
|
||||
verbose_router_logger.setLevel(numeric_level)
|
||||
verbose_logger.setLevel(numeric_level)
|
||||
|
||||
# Add the handler to the logger
|
||||
verbose_router_logger.addHandler(handler)
|
||||
verbose_proxy_logger.addHandler(handler)
|
||||
@@ -125,6 +155,13 @@ def _suppress_loggers():
|
||||
# Call the suppression function
|
||||
_suppress_loggers()
|
||||
|
||||
if file_handler:
|
||||
verbose_router_logger.addHandler(file_handler)
|
||||
verbose_proxy_logger.addHandler(file_handler)
|
||||
verbose_logger.addHandler(file_handler)
|
||||
|
||||
|
||||
|
||||
ALL_LOGGERS = [
|
||||
logging.getLogger(),
|
||||
verbose_logger,
|
||||
@@ -153,10 +190,10 @@ def _turn_on_json():
|
||||
- Adds a JSON formatter to all loggers
|
||||
"""
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(JsonFormatter())
|
||||
handler.setFormatter(json_formatter)
|
||||
_initialize_loggers_with_handler(handler)
|
||||
# Set up exception handlers
|
||||
_setup_json_exception_handlers(JsonFormatter())
|
||||
_setup_json_exception_handlers(json_formatter)
|
||||
|
||||
|
||||
def _turn_on_debug():
|
||||
|
||||
+46
-9
@@ -638,18 +638,55 @@ featherless_ai_models: set = set([
|
||||
])
|
||||
|
||||
nebius_models: set = set([
|
||||
# deepseek models
|
||||
"deepseek-ai/DeepSeek-R1-0528",
|
||||
"deepseek-ai/DeepSeek-V3-0324",
|
||||
"deepseek-ai/DeepSeek-V3",
|
||||
"deepseek-ai/DeepSeek-R1",
|
||||
"deepseek-ai/DeepSeek-R1-Distill-Llama-70B",
|
||||
# google models
|
||||
"google/gemma-2-2b-it",
|
||||
"google/gemma-2-9b-it-fast",
|
||||
# llama models
|
||||
"meta-llama/Llama-3.3-70B-Instruct",
|
||||
"meta-llama/Meta-Llama-3.1-70B-Instruct",
|
||||
"meta-llama/Meta-Llama-3.1-8B-Instruct",
|
||||
"meta-llama/Meta-Llama-3.1-405B-Instruct",
|
||||
"NousResearch/Hermes-3-Llama-405B",
|
||||
# microsoft models
|
||||
"microsoft/phi-4",
|
||||
# mistral models
|
||||
"mistralai/Mistral-Nemo-Instruct-2407",
|
||||
"mistralai/Devstral-Small-2505",
|
||||
# moonshot models
|
||||
"moonshotai/Kimi-K2-Instruct",
|
||||
# nvidia models
|
||||
"nvidia/Llama-3_1-Nemotron-Ultra-253B-v1",
|
||||
"nvidia/Llama-3_3-Nemotron-Super-49B-v1",
|
||||
# openai models
|
||||
"openai/gpt-oss-120b",
|
||||
"openai/gpt-oss-20b",
|
||||
# qwen models
|
||||
"Qwen/Qwen3-Coder-480B-A35B-Instruct",
|
||||
"Qwen/Qwen3-235B-A22B-Instruct-2507",
|
||||
"Qwen/Qwen3-235B-A22B",
|
||||
"Qwen/Qwen3-30B-A3B-fast",
|
||||
"Qwen/Qwen3-30B-A3B",
|
||||
"Qwen/Qwen3-32B",
|
||||
"Qwen/Qwen3-14B",
|
||||
"nvidia/Llama-3_1-Nemotron-Ultra-253B-v1",
|
||||
"deepseek-ai/DeepSeek-V3-0324",
|
||||
"deepseek-ai/DeepSeek-V3-0324-fast",
|
||||
"deepseek-ai/DeepSeek-R1",
|
||||
"deepseek-ai/DeepSeek-R1-fast",
|
||||
"meta-llama/Llama-3.3-70B-Instruct-fast",
|
||||
"Qwen/Qwen2.5-32B-Instruct-fast",
|
||||
"Qwen/Qwen2.5-Coder-32B-Instruct-fast",
|
||||
"Qwen/Qwen3-4B-fast",
|
||||
"Qwen/Qwen2.5-Coder-7B",
|
||||
"Qwen/Qwen2.5-Coder-32B-Instruct",
|
||||
"Qwen/Qwen2.5-72B-Instruct",
|
||||
"Qwen/QwQ-32B",
|
||||
"Qwen/Qwen3-30B-A3B-Thinking-2507",
|
||||
"Qwen/Qwen3-30B-A3B-Instruct-2507",
|
||||
# zai models
|
||||
"zai-org/GLM-4.5",
|
||||
"zai-org/GLM-4.5-Air",
|
||||
# other models
|
||||
"aaditya/Llama3-OpenBioLLM-70B",
|
||||
"ProdeusUnity/Stellar-Odyssey-12b-v0.0",
|
||||
"all-hands/openhands-lm-32b-v0.1",
|
||||
])
|
||||
|
||||
dashscope_models: set = set([
|
||||
|
||||
@@ -529,7 +529,7 @@ def _get_count_function(
|
||||
encoding = tiktoken.get_encoding("cl100k_base")
|
||||
|
||||
def count_tokens(text: str) -> int:
|
||||
return len(encoding.encode(text))
|
||||
return len(encoding.encode(text, disallowed_special=()))
|
||||
|
||||
else:
|
||||
raise ValueError("Unsupported tokenizer type")
|
||||
|
||||
@@ -1256,6 +1256,7 @@ def completion( # type: ignore # noqa: PLR0915
|
||||
additional_drop_params=kwargs.get("additional_drop_params"),
|
||||
remove_sensitive_keys=True,
|
||||
add_provider_specific_params=True,
|
||||
provider_config=provider_config,
|
||||
)
|
||||
|
||||
if litellm.add_function_to_prompt and optional_params.get(
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from litellm.types.guardrails import SupportedGuardrailIntegrations
|
||||
|
||||
from .noma import NomaGuardrail
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.types.guardrails import Guardrail, LitellmParams
|
||||
|
||||
|
||||
def initialize_guardrail(litellm_params: "LitellmParams", guardrail: "Guardrail"):
|
||||
import litellm
|
||||
|
||||
_noma_callback = NomaGuardrail(
|
||||
guardrail_name=guardrail.get("guardrail_name", ""),
|
||||
api_key=litellm_params.api_key,
|
||||
api_base=litellm_params.api_base,
|
||||
application_id=litellm_params.application_id,
|
||||
monitor_mode=litellm_params.monitor_mode,
|
||||
block_failures=litellm_params.block_failures,
|
||||
event_hook=litellm_params.mode,
|
||||
default_on=litellm_params.default_on,
|
||||
)
|
||||
litellm.logging_callback_manager.add_litellm_callback(_noma_callback)
|
||||
|
||||
return _noma_callback
|
||||
|
||||
|
||||
guardrail_initializer_registry = {
|
||||
SupportedGuardrailIntegrations.NOMA.value: initialize_guardrail,
|
||||
}
|
||||
|
||||
|
||||
guardrail_class_registry = {
|
||||
SupportedGuardrailIntegrations.NOMA.value: NomaGuardrail,
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
# +-------------------------------------------------------------+
|
||||
#
|
||||
# Noma Security Guardrail Integration for LiteLLM
|
||||
# https://noma.security
|
||||
#
|
||||
# +-------------------------------------------------------------+
|
||||
|
||||
import copy
|
||||
import os
|
||||
from typing import Any, Dict, Literal, Optional, Union
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
import litellm
|
||||
from litellm import DualCache, ModelResponse
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.integrations.custom_guardrail import CustomGuardrail
|
||||
from litellm.llms.custom_httpx.http_handler import (
|
||||
get_async_httpx_client,
|
||||
httpxSpecialProvider,
|
||||
)
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.types.guardrails import GuardrailEventHooks
|
||||
from litellm.types.utils import EmbeddingResponse, ImageResponse
|
||||
|
||||
|
||||
class NomaBlockedMessage(HTTPException):
|
||||
"""Exception raised when Noma guardrail blocks a message"""
|
||||
|
||||
def __init__(self, classification_response: dict):
|
||||
classification = self._filter_triggered_classifications(classification_response)
|
||||
super().__init__(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": "Request blocked by Noma guardrail",
|
||||
"details": classification,
|
||||
},
|
||||
)
|
||||
|
||||
def _filter_triggered_classifications(
|
||||
self,
|
||||
response_dict: dict,
|
||||
) -> dict:
|
||||
"""Filter and return only triggered classifications"""
|
||||
filtered_response = copy.deepcopy(response_dict)
|
||||
|
||||
# Filter prompt classifications if present
|
||||
if filtered_response.get("prompt"):
|
||||
filtered_response["prompt"] = self.filter_classification_object(
|
||||
filtered_response["prompt"]
|
||||
)
|
||||
|
||||
# Filter response classifications if present
|
||||
if filtered_response.get("response"):
|
||||
filtered_response["response"] = self.filter_classification_object(
|
||||
filtered_response["response"]
|
||||
)
|
||||
|
||||
return filtered_response
|
||||
|
||||
def filter_classification_object(
|
||||
self,
|
||||
classification_obj: dict,
|
||||
) -> dict:
|
||||
"""Filter classification object to only include triggered items"""
|
||||
if not classification_obj:
|
||||
return {}
|
||||
|
||||
result = {}
|
||||
|
||||
for key, value in classification_obj.items():
|
||||
if value is None:
|
||||
continue
|
||||
|
||||
if key in [
|
||||
"allowedTopics",
|
||||
"bannedTopics",
|
||||
"topicGuardrails",
|
||||
] and isinstance(value, dict):
|
||||
filtered_topics = {}
|
||||
for topic, topic_result in value.items():
|
||||
if self._is_result_true(topic_result):
|
||||
filtered_topics[topic] = topic_result
|
||||
|
||||
if filtered_topics:
|
||||
result[key] = filtered_topics
|
||||
|
||||
elif key == "sensitiveData" and isinstance(value, dict):
|
||||
filtered_sensitive = {}
|
||||
for data_type, data_result in value.items():
|
||||
if self._is_result_true(data_result):
|
||||
filtered_sensitive[data_type] = data_result
|
||||
|
||||
if filtered_sensitive:
|
||||
result[key] = filtered_sensitive
|
||||
|
||||
elif isinstance(value, dict) and "result" in value:
|
||||
if self._is_result_true(value):
|
||||
result[key] = value
|
||||
|
||||
return result
|
||||
|
||||
def _is_result_true(self, result_obj: Optional[Dict[str, Any]]) -> bool:
|
||||
"""
|
||||
Check if a result object has a "result" field that is True.
|
||||
|
||||
Args:
|
||||
result_obj: A dictionary that may contain a "result" field
|
||||
|
||||
Returns:
|
||||
True if the "result" field exists and is True, False otherwise
|
||||
"""
|
||||
if not result_obj or not isinstance(result_obj, dict):
|
||||
return False
|
||||
|
||||
return result_obj.get("result") is True
|
||||
|
||||
|
||||
class NomaGuardrail(CustomGuardrail):
|
||||
"""
|
||||
Noma Security Guardrail for LiteLLM
|
||||
|
||||
This guardrail integrates with Noma Security's AI-DR API to provide
|
||||
content moderation and safety checks for LLM inputs and outputs.
|
||||
"""
|
||||
|
||||
_DEFAULT_API_BASE = "https://api.noma.security/"
|
||||
_AIDR_ENDPOINT = "/ai-dr/v1/prompt/scan/aggregate"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: Optional[str] = None,
|
||||
api_base: Optional[str] = None,
|
||||
application_id: Optional[str] = None,
|
||||
monitor_mode: Optional[bool] = None,
|
||||
block_failures: Optional[bool] = None,
|
||||
**kwargs,
|
||||
):
|
||||
self.async_handler = get_async_httpx_client(
|
||||
llm_provider=httpxSpecialProvider.GuardrailCallback
|
||||
)
|
||||
self.api_key = api_key or os.environ.get("NOMA_API_KEY")
|
||||
self.api_base = api_base or os.environ.get(
|
||||
"NOMA_API_BASE", NomaGuardrail._DEFAULT_API_BASE
|
||||
)
|
||||
self.application_id = application_id or os.environ.get(
|
||||
"NOMA_APPLICATION_ID", "litellm"
|
||||
)
|
||||
|
||||
if monitor_mode is None:
|
||||
self.monitor_mode = (
|
||||
os.environ.get("NOMA_MONITOR_MODE", "false").lower() == "true"
|
||||
)
|
||||
else:
|
||||
self.monitor_mode = monitor_mode
|
||||
|
||||
if block_failures is None:
|
||||
self.block_failures = (
|
||||
os.environ.get("NOMA_BLOCK_FAILURES", "true").lower() == "true"
|
||||
)
|
||||
else:
|
||||
self.block_failures = block_failures
|
||||
|
||||
super().__init__(**kwargs)
|
||||
|
||||
async def async_pre_call_hook(
|
||||
self,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
cache: DualCache,
|
||||
data: dict,
|
||||
call_type: Literal[
|
||||
"completion",
|
||||
"text_completion",
|
||||
"embeddings",
|
||||
"image_generation",
|
||||
"moderation",
|
||||
"audio_transcription",
|
||||
"pass_through_endpoint",
|
||||
"rerank",
|
||||
"mcp_call",
|
||||
],
|
||||
) -> Optional[Union[Exception, str, dict]]:
|
||||
verbose_proxy_logger.debug("Running Noma pre-call hook")
|
||||
|
||||
if (
|
||||
self.should_run_guardrail(
|
||||
data=data, event_type=GuardrailEventHooks.pre_call
|
||||
)
|
||||
is False
|
||||
):
|
||||
return data
|
||||
|
||||
try:
|
||||
return await self._check_user_message(data, user_api_key_dict)
|
||||
except NomaBlockedMessage:
|
||||
raise
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.error(f"Noma pre-call hook failed: {str(e)}")
|
||||
|
||||
if self.block_failures and not self.monitor_mode:
|
||||
raise
|
||||
return data
|
||||
|
||||
async def async_moderation_hook(
|
||||
self,
|
||||
data: dict,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
call_type: Literal[
|
||||
"completion",
|
||||
"embeddings",
|
||||
"image_generation",
|
||||
"moderation",
|
||||
"audio_transcription",
|
||||
"responses",
|
||||
"mcp_call",
|
||||
],
|
||||
) -> Union[Exception, str, dict, None]:
|
||||
event_type: GuardrailEventHooks = GuardrailEventHooks.during_call
|
||||
if self.should_run_guardrail(data=data, event_type=event_type) is not True:
|
||||
return data
|
||||
|
||||
try:
|
||||
return await self._check_user_message(data, user_api_key_dict)
|
||||
except NomaBlockedMessage:
|
||||
raise
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.error(f"Noma moderation hook failed: {str(e)}")
|
||||
|
||||
if self.block_failures and not self.monitor_mode:
|
||||
raise
|
||||
return data
|
||||
|
||||
async def async_post_call_success_hook(
|
||||
self,
|
||||
data: dict,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
response: Union[Any, ModelResponse, EmbeddingResponse, ImageResponse],
|
||||
):
|
||||
event_type: GuardrailEventHooks = GuardrailEventHooks.post_call
|
||||
if self.should_run_guardrail(data=data, event_type=event_type) is not True:
|
||||
return response
|
||||
|
||||
try:
|
||||
return await self._check_llm_response(data, response, user_api_key_dict)
|
||||
except NomaBlockedMessage:
|
||||
raise
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.error(f"Noma post-call hook failed: {str(e)}")
|
||||
if self.block_failures and not self.monitor_mode:
|
||||
raise
|
||||
return response
|
||||
|
||||
async def _check_user_message(
|
||||
self,
|
||||
request_data: dict,
|
||||
user_auth: UserAPIKeyAuth,
|
||||
) -> Union[Exception, str, dict, None]:
|
||||
"""Check user message for policy violations"""
|
||||
extra_data = self.get_guardrail_dynamic_request_body_params(request_data)
|
||||
|
||||
user_message = await self._extract_user_message(request_data)
|
||||
if not user_message:
|
||||
return request_data
|
||||
|
||||
payload = {"request": {"text": user_message}}
|
||||
response_json = await self._call_noma_api(
|
||||
payload=payload,
|
||||
llm_request_id=None,
|
||||
request_data=request_data,
|
||||
user_auth=user_auth,
|
||||
extra_data=extra_data,
|
||||
)
|
||||
await self._check_verdict("user", user_message, response_json)
|
||||
|
||||
return request_data
|
||||
|
||||
async def _check_llm_response(
|
||||
self,
|
||||
request_data: dict,
|
||||
response: Union[Any, ModelResponse, EmbeddingResponse, ImageResponse],
|
||||
user_auth: UserAPIKeyAuth,
|
||||
) -> Union[Exception, ModelResponse, Any]:
|
||||
"""Check LLM response for policy violations"""
|
||||
extra_data = self.get_guardrail_dynamic_request_body_params(request_data)
|
||||
|
||||
if not isinstance(response, litellm.ModelResponse):
|
||||
return response
|
||||
|
||||
content = None
|
||||
for choice in response.choices:
|
||||
if isinstance(choice, litellm.Choices) and choice.message.content:
|
||||
content = choice.message.content
|
||||
break
|
||||
|
||||
if not content or not isinstance(content, str):
|
||||
return response
|
||||
|
||||
payload = {"response": {"text": content}}
|
||||
|
||||
response_json = await self._call_noma_api(
|
||||
payload=payload,
|
||||
llm_request_id=response.id,
|
||||
request_data=request_data,
|
||||
user_auth=user_auth,
|
||||
extra_data=extra_data,
|
||||
)
|
||||
await self._check_verdict("assistant", content, response_json)
|
||||
|
||||
return response
|
||||
|
||||
async def _extract_user_message(self, data: dict) -> Optional[str]:
|
||||
"""Extract the last user message from request data"""
|
||||
messages = data.get("messages", [])
|
||||
if not messages:
|
||||
return None
|
||||
|
||||
# Get the last user message
|
||||
user_messages = [msg for msg in messages if msg.get("role") == "user"]
|
||||
if not user_messages:
|
||||
return None
|
||||
|
||||
last_user_message = user_messages[-1].get("content", "")
|
||||
if not last_user_message or not isinstance(last_user_message, str):
|
||||
return None
|
||||
|
||||
return last_user_message
|
||||
|
||||
async def _call_noma_api(
|
||||
self,
|
||||
payload: dict,
|
||||
llm_request_id: Optional[str],
|
||||
request_data: dict,
|
||||
user_auth: UserAPIKeyAuth,
|
||||
extra_data: dict,
|
||||
) -> dict:
|
||||
call_id = request_data.get("litellm_call_id")
|
||||
headers = {
|
||||
"X-Noma-AIDR-Application-ID": self.application_id,
|
||||
**({"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}),
|
||||
**({"X-Noma-Request-ID": call_id} if call_id else {}),
|
||||
}
|
||||
endpoint = urljoin(
|
||||
self.api_base or "https://api.noma.security/", NomaGuardrail._AIDR_ENDPOINT
|
||||
)
|
||||
|
||||
response = await self.async_handler.post(
|
||||
endpoint,
|
||||
headers=headers,
|
||||
json={
|
||||
**payload,
|
||||
"context": {
|
||||
"applicationId": extra_data.get("application_id")
|
||||
or request_data.get("metadata", {})
|
||||
.get("headers", {})
|
||||
.get("x-noma-application-id"),
|
||||
"ipAddress": request_data.get("metadata", {}).get(
|
||||
"requester_ip_address", None
|
||||
),
|
||||
"userId": user_auth.user_email
|
||||
if user_auth.user_email
|
||||
else user_auth.user_id,
|
||||
"sessionId": call_id,
|
||||
"requestId": llm_request_id,
|
||||
},
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
return response.json()
|
||||
|
||||
async def _check_verdict(
|
||||
self,
|
||||
type: Literal["user", "assistant"],
|
||||
message: str,
|
||||
response_json: dict,
|
||||
) -> None:
|
||||
"""
|
||||
Check the verdict from the Noma API and raise an exception if needed
|
||||
"""
|
||||
if not response_json.get("verdict", True):
|
||||
msg = str.format(
|
||||
"Noma guardrail blocked {type} message: {message}",
|
||||
type=type,
|
||||
message=message,
|
||||
)
|
||||
|
||||
if self.monitor_mode:
|
||||
verbose_proxy_logger.warning(msg)
|
||||
else:
|
||||
verbose_proxy_logger.debug(msg)
|
||||
original_response = response_json.get("originalResponse", {})
|
||||
raise NomaBlockedMessage(original_response)
|
||||
else:
|
||||
msg = str.format(
|
||||
"Noma guardrail allowed {type} message: {message}",
|
||||
type=type,
|
||||
message=message,
|
||||
)
|
||||
if self.monitor_mode:
|
||||
verbose_proxy_logger.info(msg)
|
||||
else:
|
||||
verbose_proxy_logger.debug(msg)
|
||||
@@ -40,6 +40,7 @@ class SupportedGuardrailIntegrations(Enum):
|
||||
AZURE_TEXT_MODERATIONS = "azure/text_moderations"
|
||||
MODEL_ARMOR = "model_armor"
|
||||
OPENAI_MODERATION = "openai_moderation"
|
||||
NOMA = "noma"
|
||||
|
||||
class Role(Enum):
|
||||
SYSTEM = "system"
|
||||
@@ -359,6 +360,23 @@ class PillarGuardrailConfigModel(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class NomaGuardrailConfigModel(BaseModel):
|
||||
"""Configuration parameters for the Noma Security guardrail"""
|
||||
|
||||
application_id: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Application ID for Noma Security. Defaults to 'litellm' if not provided",
|
||||
)
|
||||
monitor_mode: Optional[bool] = Field(
|
||||
default=None,
|
||||
description="If True, logs violations without blocking. Defaults to False if not provided",
|
||||
)
|
||||
block_failures: Optional[bool] = Field(
|
||||
default=None,
|
||||
description="If True, blocks requests on API failures. Defaults to True if not provided",
|
||||
)
|
||||
|
||||
|
||||
class BaseLitellmParams(BaseModel): # works for new and patch update guardrails
|
||||
api_key: Optional[str] = Field(
|
||||
default=None, description="API key for the guardrail service"
|
||||
@@ -445,6 +463,7 @@ class LitellmParams(
|
||||
LakeraV2GuardrailConfigModel,
|
||||
LassoGuardrailConfigModel,
|
||||
PillarGuardrailConfigModel,
|
||||
NomaGuardrailConfigModel,
|
||||
BaseLitellmParams,
|
||||
):
|
||||
guardrail: str = Field(description="The type of guardrail integration to use")
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from enum import Enum
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
@@ -14,6 +13,7 @@ from typing import (
|
||||
Union,
|
||||
)
|
||||
|
||||
import fastuuid as uuid
|
||||
from aiohttp import FormData
|
||||
from openai._models import BaseModel as OpenAIObject
|
||||
from openai.types.audio.transcription_create_params import FileTypes # type: ignore
|
||||
|
||||
+1
-8
@@ -3088,6 +3088,7 @@ def pre_process_non_default_params(
|
||||
model: str,
|
||||
remove_sensitive_keys: bool = False,
|
||||
add_provider_specific_params: bool = False,
|
||||
provider_config: Optional[BaseConfig] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Pre-process non-default params to a standardized format
|
||||
@@ -3103,14 +3104,6 @@ def pre_process_non_default_params(
|
||||
additional_endpoint_specific_params=["messages"],
|
||||
)
|
||||
|
||||
provider_config: Optional[BaseConfig] = None
|
||||
if custom_llm_provider is not None and custom_llm_provider in [
|
||||
provider.value for provider in LlmProviders
|
||||
]:
|
||||
provider_config = ProviderConfigManager.get_provider_chat_config(
|
||||
model=model, provider=LlmProviders(custom_llm_provider)
|
||||
)
|
||||
|
||||
if "response_format" in non_default_params:
|
||||
if provider_config is not None:
|
||||
non_default_params[
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Generated
+36
-1
@@ -1250,6 +1250,41 @@ lz4 = ["lz4"]
|
||||
snappy = ["cramjam"]
|
||||
zstandard = ["zstandard"]
|
||||
|
||||
[[package]]
|
||||
name = "fastuuid"
|
||||
version = "0.12.0"
|
||||
description = "Python bindings to Rust's UUID library."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "fastuuid-0.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:22a900ef0956aacf862b460e20541fdae2d7c340594fe1bd6fdcb10d5f0791a9"},
|
||||
{file = "fastuuid-0.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0302f5acf54dc75de30103025c5a95db06d6c2be36829043a0aa16fc170076bc"},
|
||||
{file = "fastuuid-0.12.0-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:7946b4a310cfc2d597dcba658019d72a2851612a2cebb949d809c0e2474cf0a6"},
|
||||
{file = "fastuuid-0.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:a1b6764dd42bf0c46c858fb5ade7b7a3d93b7a27485a7a5c184909026694cd88"},
|
||||
{file = "fastuuid-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2bced35269315d16fe0c41003f8c9d63f2ee16a59295d90922cad5e6a67d0418"},
|
||||
{file = "fastuuid-0.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82106e4b0a24f4f2f73c88f89dadbc1533bb808900740ca5db9bbb17d3b0c824"},
|
||||
{file = "fastuuid-0.12.0-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:4db1bc7b8caa1d7412e1bea29b016d23a8d219131cff825b933eb3428f044dca"},
|
||||
{file = "fastuuid-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:07afc8e674e67ac3d35a608c68f6809da5fab470fb4ef4469094fdb32ba36c51"},
|
||||
{file = "fastuuid-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:328694a573fe9dce556b0b70c9d03776786801e028d82f0b6d9db1cb0521b4d1"},
|
||||
{file = "fastuuid-0.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02acaea2c955bb2035a7d8e7b3fba8bd623b03746ae278e5fa932ef54c702f9f"},
|
||||
{file = "fastuuid-0.12.0-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:ed9f449cba8cf16cced252521aee06e633d50ec48c807683f21cc1d89e193eb0"},
|
||||
{file = "fastuuid-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:0df2ea4c9db96fd8f4fa38d0e88e309b3e56f8fd03675a2f6958a5b082a0c1e4"},
|
||||
{file = "fastuuid-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7fe2407316a04ee8f06d3dbc7eae396d0a86591d92bafe2ca32fce23b1145786"},
|
||||
{file = "fastuuid-0.12.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b9b31dd488d0778c36f8279b306dc92a42f16904cba54acca71e107d65b60b0c"},
|
||||
{file = "fastuuid-0.12.0-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:b19361ee649365eefc717ec08005972d3d1eb9ee39908022d98e3bfa9da59e37"},
|
||||
{file = "fastuuid-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:8fc66b11423e6f3e1937385f655bedd67aebe56a3dcec0cb835351cfe7d358c9"},
|
||||
{file = "fastuuid-0.12.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7b15c54d300279ab20a9cc0579ada9c9f80d1bc92997fc61fb7bf3103d7cb26b"},
|
||||
{file = "fastuuid-0.12.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:458f1bc3ebbd76fdb89ad83e6b81ccd3b2a99fa6707cd3650b27606745cfb170"},
|
||||
{file = "fastuuid-0.12.0-cp38-cp38-manylinux_2_34_x86_64.whl", hash = "sha256:a8f0f83fbba6dc44271a11b22e15838641b8c45612cdf541b4822a5930f6893c"},
|
||||
{file = "fastuuid-0.12.0-cp38-cp38-win_amd64.whl", hash = "sha256:7cfd2092253d3441f6a8c66feff3c3c009da25a5b3da82bc73737558543632be"},
|
||||
{file = "fastuuid-0.12.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9303617e887429c193d036d47d0b32b774ed3618431123e9106f610d601eb57e"},
|
||||
{file = "fastuuid-0.12.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8790221325b376e1122e95f865753ebf456a9fb8faf0dca4f9bf7a3ff620e413"},
|
||||
{file = "fastuuid-0.12.0-cp39-cp39-manylinux_2_34_x86_64.whl", hash = "sha256:e4b12d3e23515e29773fa61644daa660ceb7725e05397a986c2109f512579a48"},
|
||||
{file = "fastuuid-0.12.0-cp39-cp39-win_amd64.whl", hash = "sha256:e41656457c34b5dcb784729537ea64c7d9bbaf7047b480c6c6a64c53379f455a"},
|
||||
{file = "fastuuid-0.12.0.tar.gz", hash = "sha256:d0bd4e5b35aad2826403f4411937c89e7c88857b1513fe10f696544c03e9bd8e"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filelock"
|
||||
version = "3.16.1"
|
||||
@@ -6541,4 +6576,4 @@ utils = ["numpydoc"]
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.8.1,<4.0, !=3.9.7"
|
||||
content-hash = "17a23611c832b757244c5b5dfd3a6eadae4699602a823587ea115513bfca8e4d"
|
||||
content-hash = "f41e6359109c5c52dba2a28f301b04030d865265f408974082b390bf45568a01"
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "litellm"
|
||||
version = "1.76.0"
|
||||
version = "1.76.1"
|
||||
description = "Library to easily interface with LLM API providers"
|
||||
authors = ["BerriAI"]
|
||||
license = "MIT"
|
||||
@@ -20,6 +20,7 @@ Documentation = "https://docs.litellm.ai"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = ">=3.8.1,<4.0, !=3.9.7"
|
||||
fastuuid = ">=0.12.0"
|
||||
httpx = ">=0.23.0"
|
||||
openai = ">=1.99.5"
|
||||
python-dotenv = ">=0.2.0"
|
||||
@@ -155,7 +156,7 @@ requires = ["poetry-core", "wheel"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.commitizen]
|
||||
version = "1.76.0"
|
||||
version = "1.76.1"
|
||||
version_files = [
|
||||
"pyproject.toml:^version"
|
||||
]
|
||||
|
||||
+2
-1
@@ -7,6 +7,7 @@ backoff==2.2.1 # server dep
|
||||
pyyaml==6.0.2 # server dep
|
||||
uvicorn==0.29.0 # server dep
|
||||
gunicorn==23.0.0 # server dep
|
||||
fastuuid==0.12.0 # for uuid4
|
||||
uvloop==0.21.0 # uvicorn dep, gives us much better performance under load
|
||||
boto3==1.36.0 # aws bedrock/sagemaker calls
|
||||
redis==5.2.1 # redis caching
|
||||
@@ -23,7 +24,7 @@ async_generator==1.10.0 # for async ollama calls
|
||||
langfuse==2.59.7 # for langfuse self-hosted logging
|
||||
prometheus_client==0.20.0 # for /metrics endpoint on proxy
|
||||
ddtrace==2.19.0 # for advanced DD tracing / profiling
|
||||
orjson==3.10.12 # fast /embedding responses
|
||||
orjson==3.11.2 # fast /embedding responses
|
||||
polars==1.31.0 # for data processing
|
||||
apscheduler==3.10.4 # for resetting budget in background
|
||||
fastapi-sso==0.16.0 # admin UI, SSO
|
||||
|
||||
Binary file not shown.
@@ -3,9 +3,23 @@
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import random
|
||||
import string
|
||||
|
||||
import pytest
|
||||
|
||||
# Set up a temporary log directory and file BEFORE importing litellm
|
||||
temp_dir = tempfile.mkdtemp(prefix="litellm_test_")
|
||||
test_log_file = os.path.join(temp_dir, "test_litellm.log")
|
||||
|
||||
# Store original log file for cleanup
|
||||
orig_log_file = os.getenv("LITELLM_LOG_FILE")
|
||||
|
||||
# Set environment variables to use temporary files BEFORE importing litellm
|
||||
os.environ["LITELLM_LOG_FILE"] = test_log_file
|
||||
|
||||
# Import litellm after setting up the environment
|
||||
sys.path.insert(
|
||||
0, os.path.abspath("../..")
|
||||
) # Adds the parent directory to the system path
|
||||
@@ -13,6 +27,61 @@ import asyncio
|
||||
|
||||
import litellm
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def temp_log_file():
|
||||
"""
|
||||
Creates a temporary log file in /tmp/litellm<random_number>.log for testing.
|
||||
Returns the path to the temporary log file and cleans it up after the test.
|
||||
"""
|
||||
# Generate a random number for the log file
|
||||
random_number = ''.join(random.choices(string.digits, k=8))
|
||||
log_file_path = f"/tmp/litellm{random_number}.log"
|
||||
|
||||
# Set the environment variable for litellm to use this temporary log file
|
||||
original_log_file = os.environ.get("LITELLM_LOG_FILE")
|
||||
os.environ["LITELLM_LOG_FILE"] = log_file_path
|
||||
|
||||
yield log_file_path
|
||||
|
||||
# Cleanup: Restore original environment variable and remove the temporary file
|
||||
if original_log_file is not None:
|
||||
os.environ["LITELLM_LOG_FILE"] = original_log_file
|
||||
else:
|
||||
os.environ.pop("LITELLM_LOG_FILE", None)
|
||||
|
||||
# Remove the temporary log file if it exists
|
||||
if os.path.exists(log_file_path):
|
||||
try:
|
||||
os.remove(log_file_path)
|
||||
except OSError:
|
||||
pass # Ignore errors if file can't be removed
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def cleanup_temp_log_dir():
|
||||
"""
|
||||
Cleans up the temporary log directory created at module import time.
|
||||
This runs once per test session after all tests are complete.
|
||||
"""
|
||||
yield
|
||||
|
||||
if orig_log_file is not None:
|
||||
os.environ["LITELLM_LOG_FILE"] = orig_log_file
|
||||
else:
|
||||
os.environ.pop("LITELLM_LOG_FILE", None)
|
||||
|
||||
# Cleanup: Remove the temporary directory created at module import time
|
||||
if os.path.exists(temp_dir):
|
||||
try:
|
||||
# Remove the test log file first
|
||||
if os.path.exists(test_log_file):
|
||||
os.remove(test_log_file)
|
||||
|
||||
# Remove the temporary directory
|
||||
import shutil
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
except OSError:
|
||||
pass # Ignore errors if cleanup fails
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
@@ -25,7 +94,6 @@ def event_loop():
|
||||
|
||||
|
||||
|
||||
|
||||
@pytest.fixture(scope="function", autouse=True)
|
||||
def setup_and_teardown():
|
||||
"""
|
||||
@@ -77,3 +145,4 @@ def pytest_collection_modifyitems(config, items):
|
||||
|
||||
# Reorder the items list
|
||||
items[:] = custom_logger_tests + other_tests
|
||||
|
||||
|
||||
@@ -44,17 +44,20 @@ class TestBraintrustLogger(unittest.TestCase):
|
||||
BraintrustLogger(api_key=None)
|
||||
self.assertIn("Missing keys=['BRAINTRUST_API_KEY']", str(context.exception))
|
||||
|
||||
@patch('litellm.integrations.braintrust_logging.global_braintrust_sync_http_handler')
|
||||
def test_log_success_event_with_default_span_name(self, mock_http_handler):
|
||||
@patch('litellm.integrations.braintrust_logging.HTTPHandler')
|
||||
def test_log_success_event_with_default_span_name(self, MockHTTPHandler):
|
||||
"""Test log_success_event uses default span name when not provided."""
|
||||
# Mock HTTP response
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {"id": "test-project-id"}
|
||||
mock_http_handler = Mock()
|
||||
mock_http_handler.post.return_value = mock_response
|
||||
MockHTTPHandler.return_value = mock_http_handler
|
||||
|
||||
# Setup
|
||||
logger = BraintrustLogger(api_key="test-key")
|
||||
logger.default_project_id = "test-project-id"
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {"id": "test-project-id"}
|
||||
mock_http_handler.post.return_value = mock_response
|
||||
|
||||
# Create a mock response object
|
||||
message_mock = Mock()
|
||||
message_mock.json = Mock(return_value={"content": "test"})
|
||||
@@ -62,6 +65,8 @@ class TestBraintrustLogger(unittest.TestCase):
|
||||
choice_mock = Mock()
|
||||
choice_mock.message = message_mock
|
||||
choice_mock.dict = Mock(return_value={"message": {"content": "test"}})
|
||||
# Mock the __getitem__ to support response_obj["choices"][0]["message"]
|
||||
choice_mock.__getitem__ = Mock(return_value=message_mock)
|
||||
|
||||
response_obj = Mock(spec=litellm.ModelResponse)
|
||||
response_obj.choices = [choice_mock]
|
||||
@@ -90,17 +95,20 @@ class TestBraintrustLogger(unittest.TestCase):
|
||||
json_data = call_args.kwargs['json']
|
||||
self.assertEqual(json_data['events'][0]['span_attributes']['name'], 'Chat Completion')
|
||||
|
||||
@patch('litellm.integrations.braintrust_logging.global_braintrust_sync_http_handler')
|
||||
def test_log_success_event_with_custom_span_name(self, mock_http_handler):
|
||||
@patch('litellm.integrations.braintrust_logging.HTTPHandler')
|
||||
def test_log_success_event_with_custom_span_name(self, MockHTTPHandler):
|
||||
"""Test log_success_event uses custom span name when provided."""
|
||||
# Mock HTTP response
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {"id": "test-project-id"}
|
||||
mock_http_handler = Mock()
|
||||
mock_http_handler.post.return_value = mock_response
|
||||
MockHTTPHandler.return_value = mock_http_handler
|
||||
|
||||
# Setup
|
||||
logger = BraintrustLogger(api_key="test-key")
|
||||
logger.default_project_id = "test-project-id"
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {"id": "test-project-id"}
|
||||
mock_http_handler.post.return_value = mock_response
|
||||
|
||||
# Create a mock response object
|
||||
message_mock = Mock()
|
||||
message_mock.json = Mock(return_value={"content": "test"})
|
||||
@@ -108,6 +116,7 @@ class TestBraintrustLogger(unittest.TestCase):
|
||||
choice_mock = Mock()
|
||||
choice_mock.message = message_mock
|
||||
choice_mock.dict = Mock(return_value={"message": {"content": "test"}})
|
||||
choice_mock.__getitem__ = Mock(return_value=message_mock)
|
||||
|
||||
response_obj = Mock(spec=litellm.ModelResponse)
|
||||
response_obj.choices = [choice_mock]
|
||||
@@ -135,17 +144,20 @@ class TestBraintrustLogger(unittest.TestCase):
|
||||
json_data = call_args.kwargs['json']
|
||||
self.assertEqual(json_data['events'][0]['span_attributes']['name'], 'Custom Operation')
|
||||
|
||||
@patch('litellm.integrations.braintrust_logging.global_braintrust_http_handler')
|
||||
async def test_async_log_success_event_with_default_span_name(self, mock_http_handler):
|
||||
@patch('litellm.integrations.braintrust_logging.get_async_httpx_client')
|
||||
async def test_async_log_success_event_with_default_span_name(self, mock_get_http_handler):
|
||||
"""Test async_log_success_event uses default span name when not provided."""
|
||||
# Mock async HTTP response
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {"id": "test-project-id"}
|
||||
mock_http_handler = MagicMock()
|
||||
mock_http_handler.post = MagicMock(return_value=mock_response)
|
||||
mock_get_http_handler.return_value = mock_http_handler
|
||||
|
||||
# Setup
|
||||
logger = BraintrustLogger(api_key="test-key")
|
||||
logger.default_project_id = "test-project-id"
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {"id": "test-project-id"}
|
||||
mock_http_handler.post = MagicMock(return_value=mock_response)
|
||||
|
||||
# Create a mock response object
|
||||
message_mock = Mock()
|
||||
message_mock.json = Mock(return_value={"content": "test"})
|
||||
@@ -153,6 +165,7 @@ class TestBraintrustLogger(unittest.TestCase):
|
||||
choice_mock = Mock()
|
||||
choice_mock.message = message_mock
|
||||
choice_mock.dict = Mock(return_value={"message": {"content": "test"}})
|
||||
choice_mock.__getitem__ = Mock(return_value=message_mock)
|
||||
|
||||
response_obj = Mock(spec=litellm.ModelResponse)
|
||||
response_obj.choices = [choice_mock]
|
||||
@@ -180,17 +193,20 @@ class TestBraintrustLogger(unittest.TestCase):
|
||||
json_data = call_args.kwargs['json']
|
||||
self.assertEqual(json_data['events'][0]['span_attributes']['name'], 'Chat Completion')
|
||||
|
||||
@patch('litellm.integrations.braintrust_logging.global_braintrust_http_handler')
|
||||
async def test_async_log_success_event_with_custom_span_name(self, mock_http_handler):
|
||||
@patch('litellm.integrations.braintrust_logging.get_async_httpx_client')
|
||||
async def test_async_log_success_event_with_custom_span_name(self, mock_get_http_handler):
|
||||
"""Test async_log_success_event uses custom span name when provided."""
|
||||
# Mock async HTTP response
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {"id": "test-project-id"}
|
||||
mock_http_handler = MagicMock()
|
||||
mock_http_handler.post = MagicMock(return_value=mock_response)
|
||||
mock_get_http_handler.return_value = mock_http_handler
|
||||
|
||||
# Setup
|
||||
logger = BraintrustLogger(api_key="test-key")
|
||||
logger.default_project_id = "test-project-id"
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {"id": "test-project-id"}
|
||||
mock_http_handler.post = MagicMock(return_value=mock_response)
|
||||
|
||||
# Create a mock response object
|
||||
message_mock = Mock()
|
||||
message_mock.json = Mock(return_value={"content": "test"})
|
||||
@@ -198,6 +214,7 @@ class TestBraintrustLogger(unittest.TestCase):
|
||||
choice_mock = Mock()
|
||||
choice_mock.message = message_mock
|
||||
choice_mock.dict = Mock(return_value={"message": {"content": "test"}})
|
||||
choice_mock.__getitem__ = Mock(return_value=message_mock)
|
||||
|
||||
response_obj = Mock(spec=litellm.ModelResponse)
|
||||
response_obj.choices = [choice_mock]
|
||||
@@ -225,17 +242,20 @@ class TestBraintrustLogger(unittest.TestCase):
|
||||
json_data = call_args.kwargs['json']
|
||||
self.assertEqual(json_data['events'][0]['span_attributes']['name'], 'Async Custom Operation')
|
||||
|
||||
@patch('litellm.integrations.braintrust_logging.global_braintrust_sync_http_handler')
|
||||
def test_span_name_with_multiple_metadata_fields(self, mock_http_handler):
|
||||
@patch('litellm.integrations.braintrust_logging.HTTPHandler')
|
||||
def test_span_name_with_multiple_metadata_fields(self, MockHTTPHandler):
|
||||
"""Test that span_name works correctly alongside other metadata fields."""
|
||||
# Mock HTTP response
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {"id": "test-project-id"}
|
||||
mock_http_handler = Mock()
|
||||
mock_http_handler.post.return_value = mock_response
|
||||
MockHTTPHandler.return_value = mock_http_handler
|
||||
|
||||
# Setup
|
||||
logger = BraintrustLogger(api_key="test-key")
|
||||
logger.default_project_id = "test-project-id"
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {"id": "test-project-id"}
|
||||
mock_http_handler.post.return_value = mock_response
|
||||
|
||||
# Create a mock response object
|
||||
message_mock = Mock()
|
||||
message_mock.json = Mock(return_value={"content": "test"})
|
||||
@@ -243,6 +263,7 @@ class TestBraintrustLogger(unittest.TestCase):
|
||||
choice_mock = Mock()
|
||||
choice_mock.message = message_mock
|
||||
choice_mock.dict = Mock(return_value={"message": {"content": "test"}})
|
||||
choice_mock.__getitem__ = Mock(return_value=message_mock)
|
||||
|
||||
response_obj = Mock(spec=litellm.ModelResponse)
|
||||
response_obj.choices = [choice_mock]
|
||||
|
||||
@@ -11,16 +11,18 @@ from litellm.integrations.braintrust_logging import BraintrustLogger
|
||||
class TestBraintrustSpanName(unittest.TestCase):
|
||||
"""Test custom span_name functionality in Braintrust logging."""
|
||||
|
||||
@patch('litellm.integrations.braintrust_logging.global_braintrust_sync_http_handler')
|
||||
def test_default_span_name(self, mock_http_handler):
|
||||
@patch('litellm.integrations.braintrust_logging.HTTPHandler')
|
||||
def test_default_span_name(self, MockHTTPHandler):
|
||||
"""Test that default span name is 'Chat Completion' when not provided."""
|
||||
# Mock HTTP response
|
||||
mock_http_handler = Mock()
|
||||
mock_http_handler.post.return_value = Mock()
|
||||
MockHTTPHandler.return_value = mock_http_handler
|
||||
|
||||
# Setup
|
||||
logger = BraintrustLogger(api_key="test-key")
|
||||
logger.default_project_id = "test-project-id"
|
||||
|
||||
# Mock HTTP response
|
||||
mock_http_handler.post.return_value = Mock()
|
||||
|
||||
# Create a properly structured mock response
|
||||
response_obj = litellm.ModelResponse(
|
||||
id="test-id",
|
||||
@@ -52,16 +54,18 @@ class TestBraintrustSpanName(unittest.TestCase):
|
||||
json_data = call_args.kwargs['json']
|
||||
self.assertEqual(json_data['events'][0]['span_attributes']['name'], 'Chat Completion')
|
||||
|
||||
@patch('litellm.integrations.braintrust_logging.global_braintrust_sync_http_handler')
|
||||
def test_custom_span_name(self, mock_http_handler):
|
||||
@patch('litellm.integrations.braintrust_logging.HTTPHandler')
|
||||
def test_custom_span_name(self, MockHTTPHandler):
|
||||
"""Test that custom span name is used when provided in metadata."""
|
||||
# Mock HTTP response
|
||||
mock_http_handler = Mock()
|
||||
mock_http_handler.post.return_value = Mock()
|
||||
MockHTTPHandler.return_value = mock_http_handler
|
||||
|
||||
# Setup
|
||||
logger = BraintrustLogger(api_key="test-key")
|
||||
logger.default_project_id = "test-project-id"
|
||||
|
||||
# Mock HTTP response
|
||||
mock_http_handler.post.return_value = Mock()
|
||||
|
||||
# Create a properly structured mock response
|
||||
response_obj = litellm.ModelResponse(
|
||||
id="test-id",
|
||||
@@ -93,16 +97,18 @@ class TestBraintrustSpanName(unittest.TestCase):
|
||||
json_data = call_args.kwargs['json']
|
||||
self.assertEqual(json_data['events'][0]['span_attributes']['name'], 'Custom Operation')
|
||||
|
||||
@patch('litellm.integrations.braintrust_logging.global_braintrust_sync_http_handler')
|
||||
def test_span_name_with_other_metadata(self, mock_http_handler):
|
||||
@patch('litellm.integrations.braintrust_logging.HTTPHandler')
|
||||
def test_span_name_with_other_metadata(self, MockHTTPHandler):
|
||||
"""Test that span_name works alongside other metadata fields."""
|
||||
# Mock HTTP response
|
||||
mock_http_handler = Mock()
|
||||
mock_http_handler.post.return_value = Mock()
|
||||
MockHTTPHandler.return_value = mock_http_handler
|
||||
|
||||
# Setup
|
||||
logger = BraintrustLogger(api_key="test-key")
|
||||
logger.default_project_id = "test-project-id"
|
||||
|
||||
# Mock HTTP response
|
||||
mock_http_handler.post.return_value = Mock()
|
||||
|
||||
# Create a properly structured mock response
|
||||
response_obj = litellm.ModelResponse(
|
||||
id="test-id",
|
||||
@@ -153,16 +159,18 @@ class TestBraintrustSpanName(unittest.TestCase):
|
||||
# Span name should be in span_attributes, not in metadata
|
||||
self.assertIn('span_name', event_metadata) # span_name is also kept in metadata
|
||||
|
||||
@patch('litellm.integrations.braintrust_logging.global_braintrust_http_handler')
|
||||
async def test_async_custom_span_name(self, mock_http_handler):
|
||||
@patch('litellm.integrations.braintrust_logging.get_async_httpx_client')
|
||||
async def test_async_custom_span_name(self, mock_get_http_handler):
|
||||
"""Test async logging with custom span name."""
|
||||
# Mock async HTTP response
|
||||
mock_http_handler = MagicMock()
|
||||
mock_http_handler.post = MagicMock(return_value=Mock())
|
||||
mock_get_http_handler.return_value = mock_http_handler
|
||||
|
||||
# Setup
|
||||
logger = BraintrustLogger(api_key="test-key")
|
||||
logger.default_project_id = "test-project-id"
|
||||
|
||||
# Mock async HTTP response
|
||||
mock_http_handler.post = MagicMock(return_value=Mock())
|
||||
|
||||
# Create a properly structured mock response
|
||||
response_obj = litellm.ModelResponse(
|
||||
id="test-id",
|
||||
|
||||
@@ -451,6 +451,7 @@ def test_img_url_token_counter(img_url):
|
||||
|
||||
def test_token_encode_disallowed_special():
|
||||
encode(model="gpt-3.5-turbo", text="Hello, world! <|endoftext|>")
|
||||
token_counter(model="gpt-3.5-turbo", text="Hello, world! <|endoftext|>")
|
||||
|
||||
|
||||
def test_token_counter():
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm import ModelResponse
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.guardrails.guardrail_hooks.noma import (
|
||||
NomaGuardrail,
|
||||
initialize_guardrail,
|
||||
)
|
||||
from litellm.proxy.guardrails.guardrail_hooks.noma.noma import NomaBlockedMessage
|
||||
from litellm.proxy.guardrails.init_guardrails import init_guardrails_v2
|
||||
from litellm.types.utils import Choices, Message
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def noma_guardrail():
|
||||
"""Create a NomaGuardrail instance for testing"""
|
||||
return NomaGuardrail(
|
||||
api_key="test-api-key",
|
||||
api_base="https://api.test.noma.security/",
|
||||
application_id="test-app",
|
||||
monitor_mode=False,
|
||||
block_failures=True,
|
||||
guardrail_name="test-noma-guardrail",
|
||||
event_hook="pre_call",
|
||||
default_on=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user_api_key_dict():
|
||||
"""Create a mock UserAPIKeyAuth object"""
|
||||
return UserAPIKeyAuth(
|
||||
user_id="test-user-id",
|
||||
user_email="test@example.com",
|
||||
key_name="test-key",
|
||||
key_alias=None,
|
||||
team_id=None,
|
||||
team_alias=None,
|
||||
user_role=None,
|
||||
api_key="test-api-key",
|
||||
permissions={},
|
||||
models=[],
|
||||
spend=0.0,
|
||||
max_budget=None,
|
||||
soft_budget=None,
|
||||
tpm_limit=None,
|
||||
rpm_limit=None,
|
||||
parallel_request_limit=None,
|
||||
metadata={},
|
||||
max_parallel_requests=None,
|
||||
allowed_cache_controls=[],
|
||||
model_spend={},
|
||||
model_max_budget={},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_request_data():
|
||||
"""Create mock request data"""
|
||||
return {
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are a helpful assistant"},
|
||||
{"role": "user", "content": "Hello, how are you?"},
|
||||
],
|
||||
"litellm_call_id": "test-call-id",
|
||||
"metadata": {"requester_ip_address": "192.168.1.1"},
|
||||
}
|
||||
|
||||
|
||||
class TestNomaGuardrailConfiguration:
|
||||
"""Test configuration and initialization of Noma guardrail"""
|
||||
|
||||
def test_init_with_config(self):
|
||||
"""Test initializing Noma guardrail via init_guardrails_v2"""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"NOMA_API_KEY": "test-api-key",
|
||||
"NOMA_API_BASE": "https://api.test.noma.security/",
|
||||
},
|
||||
):
|
||||
init_guardrails_v2(
|
||||
all_guardrails=[
|
||||
{
|
||||
"guardrail_name": "noma-pre-guard",
|
||||
"litellm_params": {
|
||||
"guardrail": "noma",
|
||||
"mode": "pre_call",
|
||||
"application_id": "test-app",
|
||||
"monitor_mode": False,
|
||||
"block_failures": True,
|
||||
},
|
||||
}
|
||||
],
|
||||
config_file_path="",
|
||||
)
|
||||
|
||||
def test_init_with_env_vars(self):
|
||||
"""Test initialization with environment variables"""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"NOMA_API_KEY": "env-api-key",
|
||||
"NOMA_API_BASE": "https://env.api.noma.security/",
|
||||
"NOMA_APPLICATION_ID": "env-app-id",
|
||||
"NOMA_MONITOR_MODE": "true",
|
||||
"NOMA_BLOCK_FAILURES": "false",
|
||||
},
|
||||
):
|
||||
guardrail = NomaGuardrail()
|
||||
assert guardrail.api_key == "env-api-key"
|
||||
assert guardrail.api_base == "https://env.api.noma.security/"
|
||||
assert guardrail.application_id == "env-app-id"
|
||||
assert guardrail.monitor_mode is True
|
||||
assert guardrail.block_failures is False
|
||||
|
||||
def test_init_with_params_override_env(self):
|
||||
"""Test that constructor params override environment variables"""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"NOMA_API_KEY": "env-api-key",
|
||||
"NOMA_MONITOR_MODE": "true",
|
||||
},
|
||||
):
|
||||
guardrail = NomaGuardrail(
|
||||
api_key="param-api-key",
|
||||
monitor_mode=False,
|
||||
)
|
||||
assert guardrail.api_key == "param-api-key"
|
||||
assert guardrail.monitor_mode is False
|
||||
|
||||
def test_initialize_guardrail_function(self):
|
||||
"""Test the initialize_guardrail function"""
|
||||
from litellm.types.guardrails import Guardrail, LitellmParams
|
||||
|
||||
litellm_params = LitellmParams(
|
||||
guardrail="noma",
|
||||
mode="pre_call",
|
||||
api_key="test-key",
|
||||
api_base="https://test.api/",
|
||||
application_id="test-app",
|
||||
monitor_mode=True,
|
||||
block_failures=False,
|
||||
)
|
||||
|
||||
guardrail = Guardrail(
|
||||
guardrail_name="test-guardrail",
|
||||
litellm_params=litellm_params,
|
||||
)
|
||||
|
||||
with patch("litellm.logging_callback_manager.add_litellm_callback") as mock_add:
|
||||
result = initialize_guardrail(litellm_params, guardrail)
|
||||
|
||||
assert isinstance(result, NomaGuardrail)
|
||||
assert result.api_key == "test-key"
|
||||
assert result.api_base == "https://test.api/"
|
||||
assert result.application_id == "test-app"
|
||||
assert result.monitor_mode is True
|
||||
assert result.block_failures is False
|
||||
mock_add.assert_called_once_with(result)
|
||||
|
||||
|
||||
class TestNomaBlockedMessage:
|
||||
"""Test the NomaBlockedMessage exception class"""
|
||||
|
||||
def test_blocked_message_basic(self):
|
||||
"""Test basic blocked message creation"""
|
||||
response = {
|
||||
"verdict": False,
|
||||
"prompt": {
|
||||
"harmfulContent": {"result": True, "confidence": 0.9},
|
||||
"code": {"result": False, "confidence": 0.1},
|
||||
},
|
||||
}
|
||||
|
||||
exception = NomaBlockedMessage(response)
|
||||
assert exception.status_code == 400
|
||||
assert exception.detail["error"] == "Request blocked by Noma guardrail"
|
||||
assert "harmfulContent" in exception.detail["details"]["prompt"]
|
||||
assert "code" not in exception.detail["details"]["prompt"]
|
||||
|
||||
def test_blocked_message_with_sensitive_data(self):
|
||||
"""Test blocked message with sensitive data detection"""
|
||||
response = {
|
||||
"verdict": False,
|
||||
"prompt": {
|
||||
"sensitiveData": {
|
||||
"email": {"result": True, "entities": ["test@example.com"]},
|
||||
"phone": {"result": False},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
exception = NomaBlockedMessage(response)
|
||||
assert "email" in exception.detail["details"]["prompt"]["sensitiveData"]
|
||||
assert "phone" not in exception.detail["details"]["prompt"]["sensitiveData"]
|
||||
|
||||
def test_blocked_message_with_topics(self):
|
||||
"""Test blocked message with topic guardrails"""
|
||||
response = {
|
||||
"verdict": False,
|
||||
"prompt": {
|
||||
"bannedTopics": {
|
||||
"violence": {"result": True, "confidence": 0.95},
|
||||
"politics": {"result": False, "confidence": 0.2},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
exception = NomaBlockedMessage(response)
|
||||
assert "violence" in exception.detail["details"]["prompt"]["bannedTopics"]
|
||||
assert "politics" not in exception.detail["details"]["prompt"]["bannedTopics"]
|
||||
|
||||
|
||||
class TestNomaGuardrailHooks:
|
||||
"""Test the guardrail hook methods"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pre_call_hook_allowed(
|
||||
self, noma_guardrail, mock_user_api_key_dict, mock_request_data
|
||||
):
|
||||
"""Test pre-call hook when content is allowed"""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"verdict": True}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch.object(
|
||||
noma_guardrail.async_handler, "post", return_value=mock_response
|
||||
) as mock_post:
|
||||
result = await noma_guardrail.async_pre_call_hook(
|
||||
user_api_key_dict=mock_user_api_key_dict,
|
||||
cache=MagicMock(),
|
||||
data=mock_request_data,
|
||||
call_type="completion",
|
||||
)
|
||||
|
||||
assert result == mock_request_data
|
||||
mock_post.assert_called_once()
|
||||
|
||||
# Verify API call details
|
||||
call_args = mock_post.call_args
|
||||
assert call_args[0][0].endswith("/ai-dr/v1/prompt/scan/aggregate")
|
||||
assert call_args[1]["headers"]["X-Noma-AIDR-Application-ID"] == "test-app"
|
||||
assert call_args[1]["headers"]["Authorization"] == "Bearer test-api-key"
|
||||
assert call_args[1]["json"]["request"]["text"] == "Hello, how are you?"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pre_call_hook_blocked(
|
||||
self, noma_guardrail, mock_user_api_key_dict, mock_request_data
|
||||
):
|
||||
"""Test pre-call hook when content is blocked"""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"verdict": False,
|
||||
"originalResponse": {
|
||||
"prompt": {"harmfulContent": {"result": True, "confidence": 0.9}}
|
||||
},
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch.object(
|
||||
noma_guardrail.async_handler, "post", return_value=mock_response
|
||||
):
|
||||
with pytest.raises(NomaBlockedMessage) as exc_info:
|
||||
await noma_guardrail.async_pre_call_hook(
|
||||
user_api_key_dict=mock_user_api_key_dict,
|
||||
cache=MagicMock(),
|
||||
data=mock_request_data,
|
||||
call_type="completion",
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "harmfulContent" in exc_info.value.detail["details"]["prompt"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pre_call_hook_monitor_mode(
|
||||
self, mock_user_api_key_dict, mock_request_data
|
||||
):
|
||||
"""Test pre-call hook in monitor mode (logs but doesn't block)"""
|
||||
guardrail = NomaGuardrail(
|
||||
api_key="test-key",
|
||||
monitor_mode=True,
|
||||
guardrail_name="test-guardrail",
|
||||
event_hook="pre_call",
|
||||
default_on=True,
|
||||
)
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"verdict": False,
|
||||
"originalResponse": {"prompt": {"harmfulContent": {"result": True}}},
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch.object(guardrail.async_handler, "post", return_value=mock_response):
|
||||
# Should not raise exception in monitor mode
|
||||
result = await guardrail.async_pre_call_hook(
|
||||
user_api_key_dict=mock_user_api_key_dict,
|
||||
cache=MagicMock(),
|
||||
data=mock_request_data,
|
||||
call_type="completion",
|
||||
)
|
||||
|
||||
assert result == mock_request_data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_call_success_hook(
|
||||
self, noma_guardrail, mock_user_api_key_dict, mock_request_data
|
||||
):
|
||||
"""Test post-call success hook"""
|
||||
# Create a mock ModelResponse
|
||||
response = ModelResponse(
|
||||
id="test-response-id",
|
||||
choices=[
|
||||
Choices(
|
||||
finish_reason="stop",
|
||||
index=0,
|
||||
message=Message(
|
||||
content="I'm doing well, thank you!", role="assistant"
|
||||
),
|
||||
)
|
||||
],
|
||||
created=1234567890,
|
||||
model="gpt-3.5-turbo",
|
||||
object="chat.completion",
|
||||
system_fingerprint=None,
|
||||
usage={"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30},
|
||||
)
|
||||
|
||||
mock_api_response = MagicMock()
|
||||
mock_api_response.json.return_value = {"verdict": True}
|
||||
mock_api_response.raise_for_status = MagicMock()
|
||||
|
||||
# Update guardrail to use post_call event hook
|
||||
noma_guardrail.event_hook = "post_call"
|
||||
|
||||
with patch.object(
|
||||
noma_guardrail.async_handler, "post", return_value=mock_api_response
|
||||
) as mock_post:
|
||||
result = await noma_guardrail.async_post_call_success_hook(
|
||||
data=mock_request_data,
|
||||
user_api_key_dict=mock_user_api_key_dict,
|
||||
response=response,
|
||||
)
|
||||
|
||||
assert result == response
|
||||
mock_post.assert_called_once()
|
||||
|
||||
# Verify API call details
|
||||
call_args = mock_post.call_args
|
||||
assert (
|
||||
call_args[1]["json"]["response"]["text"] == "I'm doing well, thank you!"
|
||||
)
|
||||
assert call_args[1]["json"]["context"]["requestId"] == "test-response-id"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_moderation_hook(
|
||||
self, noma_guardrail, mock_user_api_key_dict, mock_request_data
|
||||
):
|
||||
"""Test moderation hook (during_call)"""
|
||||
# Update guardrail to use during_call event hook
|
||||
noma_guardrail.event_hook = "during_call"
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"verdict": True}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch.object(
|
||||
noma_guardrail.async_handler, "post", return_value=mock_response
|
||||
):
|
||||
result = await noma_guardrail.async_moderation_hook(
|
||||
data=mock_request_data,
|
||||
user_api_key_dict=mock_user_api_key_dict,
|
||||
call_type="completion",
|
||||
)
|
||||
|
||||
assert result == mock_request_data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_failure_handling(
|
||||
self, noma_guardrail, mock_user_api_key_dict, mock_request_data
|
||||
):
|
||||
with patch.object(
|
||||
noma_guardrail.async_handler,
|
||||
"post",
|
||||
side_effect=httpx.HTTPStatusError(
|
||||
"API Error", request=MagicMock(), response=MagicMock(status_code=500)
|
||||
),
|
||||
):
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
await noma_guardrail.async_pre_call_hook(
|
||||
user_api_key_dict=mock_user_api_key_dict,
|
||||
cache=MagicMock(),
|
||||
data=mock_request_data,
|
||||
call_type="completion",
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_failure_no_block(
|
||||
self, mock_user_api_key_dict, mock_request_data
|
||||
):
|
||||
guardrail = NomaGuardrail(
|
||||
api_key="test-key",
|
||||
block_failures=False,
|
||||
guardrail_name="test-guardrail",
|
||||
event_hook="pre_call",
|
||||
default_on=True,
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
guardrail.async_handler,
|
||||
"post",
|
||||
side_effect=httpx.HTTPStatusError(
|
||||
"API Error", request=MagicMock(), response=MagicMock(status_code=500)
|
||||
),
|
||||
):
|
||||
result = await guardrail.async_pre_call_hook(
|
||||
user_api_key_dict=mock_user_api_key_dict,
|
||||
cache=MagicMock(),
|
||||
data=mock_request_data,
|
||||
call_type="completion",
|
||||
)
|
||||
|
||||
assert result == mock_request_data
|
||||
|
||||
def test_extract_user_message(self, noma_guardrail):
|
||||
data = {
|
||||
"messages": [
|
||||
{"role": "system", "content": "System prompt"},
|
||||
{"role": "user", "content": "First user message"},
|
||||
{"role": "assistant", "content": "Assistant response"},
|
||||
{"role": "user", "content": "Second user message"},
|
||||
]
|
||||
}
|
||||
|
||||
import asyncio
|
||||
|
||||
message = asyncio.run(noma_guardrail._extract_user_message(data))
|
||||
assert message == "Second user message"
|
||||
|
||||
data = {"messages": [{"role": "system", "content": "System prompt"}]}
|
||||
message = asyncio.run(noma_guardrail._extract_user_message(data))
|
||||
assert message is None
|
||||
|
||||
data = {"messages": []}
|
||||
message = asyncio.run(noma_guardrail._extract_user_message(data))
|
||||
assert message is None
|
||||
|
||||
data = {}
|
||||
message = asyncio.run(noma_guardrail._extract_user_message(data))
|
||||
assert message is None
|
||||
|
||||
|
||||
class TestIntegration:
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_guardrail_flow(self):
|
||||
"""Test full guardrail flow with multiple hooks"""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"NOMA_API_KEY": "test-api-key",
|
||||
"NOMA_API_BASE": "https://api.test.noma.security/",
|
||||
},
|
||||
):
|
||||
init_guardrails_v2(
|
||||
all_guardrails=[
|
||||
{
|
||||
"guardrail_name": "noma-pre-guard",
|
||||
"litellm_params": {
|
||||
"guardrail": "noma",
|
||||
"mode": "pre_call",
|
||||
"application_id": "test-app",
|
||||
},
|
||||
},
|
||||
{
|
||||
"guardrail_name": "noma-post-guard",
|
||||
"litellm_params": {
|
||||
"guardrail": "noma",
|
||||
"mode": "post_call",
|
||||
"application_id": "test-app",
|
||||
},
|
||||
},
|
||||
],
|
||||
config_file_path="",
|
||||
)
|
||||
|
||||
custom_loggers = (
|
||||
litellm.logging_callback_manager.get_custom_loggers_for_type(
|
||||
callback_type=litellm.integrations.custom_guardrail.CustomGuardrail
|
||||
)
|
||||
)
|
||||
assert len(custom_loggers) >= 2
|
||||
@@ -0,0 +1,638 @@
|
||||
import os
|
||||
import tempfile
|
||||
import re
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
# Import the loggers from litellm._logging
|
||||
from litellm._logging import verbose_logger, verbose_proxy_logger, verbose_router_logger
|
||||
|
||||
|
||||
class TestLoggingBehavior:
|
||||
"""Test suite to verify logging behavior for all LiteLLM loggers."""
|
||||
|
||||
def read_log_file_contents(self, log_file_path):
|
||||
"""Helper method to read and return contents of log file."""
|
||||
if not os.path.exists(log_file_path):
|
||||
return ""
|
||||
|
||||
with open(log_file_path, 'r') as f:
|
||||
return f.read()
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_log_file(self, temp_log_file):
|
||||
"""Use the temp_log_file fixture to ensure proper isolation."""
|
||||
self.temp_log_path = temp_log_file
|
||||
|
||||
# Set environment variable before importing/reloading
|
||||
original_log_file = os.environ.get("LITELLM_LOG_FILE")
|
||||
os.environ["LITELLM_LOG_FILE"] = temp_log_file
|
||||
|
||||
# Force reload of the logging module to pick up new environment variable
|
||||
import importlib
|
||||
import litellm._logging
|
||||
importlib.reload(litellm._logging)
|
||||
|
||||
yield
|
||||
|
||||
# Cleanup: Restore original environment variable
|
||||
if original_log_file is not None:
|
||||
os.environ["LITELLM_LOG_FILE"] = original_log_file
|
||||
else:
|
||||
os.environ.pop("LITELLM_LOG_FILE", None)
|
||||
|
||||
# Reload again to restore original state
|
||||
importlib.reload(litellm._logging)
|
||||
|
||||
def test_verbose_logger_info_level(self):
|
||||
"""Test that verbose_logger writes to file with INFO level."""
|
||||
test_message = "INFO level test message from verbose_logger"
|
||||
|
||||
# Log at INFO level
|
||||
verbose_logger.info(test_message)
|
||||
|
||||
# Force flush all handlers to ensure they write to disk
|
||||
for handler in verbose_logger.handlers:
|
||||
if hasattr(handler, 'flush'):
|
||||
handler.flush()
|
||||
|
||||
# Read log file contents
|
||||
log_file_path = os.environ.get("LITELLM_LOG_FILE")
|
||||
assert log_file_path is not None, "LITELLM_LOG_FILE environment variable should be set"
|
||||
|
||||
log_contents = self.read_log_file_contents(log_file_path)
|
||||
assert test_message in log_contents, f"Message '{test_message}' should be found in log file"
|
||||
|
||||
def test_verbose_logger_debug_level(self):
|
||||
"""Test that verbose_logger writes to file with DEBUG level."""
|
||||
test_message = "DEBUG level test message from verbose_logger"
|
||||
|
||||
# Log at DEBUG level
|
||||
verbose_logger.debug(test_message)
|
||||
|
||||
# Read log file contents
|
||||
log_file_path = os.environ.get("LITELLM_LOG_FILE")
|
||||
assert log_file_path is not None, "LITELLM_LOG_FILE environment variable should be set"
|
||||
|
||||
log_contents = self.read_log_file_contents(log_file_path)
|
||||
assert test_message in log_contents, f"Message '{test_message}' should be found in log file"
|
||||
|
||||
def test_verbose_proxy_logger_info_level(self):
|
||||
"""Test that verbose_proxy_logger writes to file with INFO level."""
|
||||
test_message = "INFO level test message from verbose_proxy_logger"
|
||||
|
||||
# Log at INFO level
|
||||
verbose_proxy_logger.info(test_message)
|
||||
|
||||
# Read log file contents
|
||||
log_file_path = os.environ.get("LITELLM_LOG_FILE")
|
||||
assert log_file_path is not None, "LITELLM_LOG_FILE environment variable should be set"
|
||||
|
||||
log_contents = self.read_log_file_contents(log_file_path)
|
||||
assert test_message in log_contents, f"Message '{test_message}' should be found in log file"
|
||||
|
||||
def test_verbose_proxy_logger_debug_level(self):
|
||||
"""Test that verbose_proxy_logger writes to file with DEBUG level."""
|
||||
test_message = "DEBUG level test message from verbose_proxy_logger"
|
||||
|
||||
# Log at DEBUG level
|
||||
verbose_proxy_logger.debug(test_message)
|
||||
|
||||
# Read log file contents
|
||||
log_file_path = os.environ.get("LITELLM_LOG_FILE")
|
||||
assert log_file_path is not None, "LITELLM_LOG_FILE environment variable should be set"
|
||||
|
||||
log_contents = self.read_log_file_contents(log_file_path)
|
||||
assert test_message in log_contents, f"Message '{test_message}' should be found in log file"
|
||||
|
||||
def test_verbose_router_logger_info_level(self):
|
||||
"""Test that verbose_router_logger writes to file with INFO level."""
|
||||
test_message = "INFO level test message from verbose_router_logger"
|
||||
|
||||
# Log at INFO level
|
||||
verbose_router_logger.info(test_message)
|
||||
|
||||
# Read log file contents
|
||||
log_file_path = os.environ.get("LITELLM_LOG_FILE")
|
||||
assert log_file_path is not None, "LITELLM_LOG_FILE environment variable should be set"
|
||||
|
||||
log_contents = self.read_log_file_contents(log_file_path)
|
||||
assert test_message in log_contents, f"Message '{test_message}' should be found in log file"
|
||||
|
||||
def test_verbose_router_logger_debug_level(self):
|
||||
"""Test that verbose_router_logger writes to file with DEBUG level."""
|
||||
test_message = "DEBUG level test message from verbose_router_logger"
|
||||
|
||||
# Log at DEBUG level
|
||||
verbose_router_logger.debug(test_message)
|
||||
|
||||
# Read log file contents
|
||||
log_file_path = os.environ.get("LITELLM_LOG_FILE")
|
||||
assert log_file_path is not None, "LITELLM_LOG_FILE environment variable should be set"
|
||||
|
||||
log_contents = self.read_log_file_contents(log_file_path)
|
||||
assert test_message in log_contents, f"Message '{test_message}' should be found in log file"
|
||||
|
||||
def test_log_format_includes_timestamp_and_level(self):
|
||||
"""Test that log entries include timestamp and level information."""
|
||||
test_message = "Format test message"
|
||||
|
||||
# Log at INFO level
|
||||
verbose_logger.info(test_message)
|
||||
|
||||
# Read log file contents
|
||||
log_file_path = os.environ.get("LITELLM_LOG_FILE")
|
||||
assert log_file_path is not None, "LITELLM_LOG_FILE environment variable should be set"
|
||||
|
||||
log_contents = self.read_log_file_contents(log_file_path)
|
||||
|
||||
# Check for timestamp format (should be in HH:MM:SS format based on _logging.py)
|
||||
assert re.search(r'\d{2}:\d{2}:\d{2}', log_contents), "Log should contain timestamp in HH:MM:SS format"
|
||||
|
||||
# Check for level information
|
||||
assert 'INFO' in log_contents, "Log should contain INFO level indicator"
|
||||
|
||||
# Check for logger name
|
||||
assert 'LiteLLM' in log_contents, "Log should contain LiteLLM logger name"
|
||||
|
||||
def test_multiple_loggers_write_to_same_file(self):
|
||||
"""Test that all loggers write to the same file."""
|
||||
messages = {
|
||||
'verbose_logger': "Message from verbose_logger",
|
||||
'verbose_proxy_logger': "Message from verbose_proxy_logger",
|
||||
'verbose_router_logger': "Message from verbose_router_logger"
|
||||
}
|
||||
|
||||
# Log messages from different loggers
|
||||
verbose_logger.info(messages['verbose_logger'])
|
||||
verbose_proxy_logger.info(messages['verbose_proxy_logger'])
|
||||
verbose_router_logger.info(messages['verbose_router_logger'])
|
||||
|
||||
# Read log file contents
|
||||
log_file_path = os.environ.get("LITELLM_LOG_FILE")
|
||||
assert log_file_path is not None, "LITELLM_LOG_FILE environment variable should be set"
|
||||
|
||||
log_contents = self.read_log_file_contents(log_file_path)
|
||||
|
||||
# Verify all messages are in the same file
|
||||
for message in messages.values():
|
||||
assert message in log_contents, f"Message '{message}' should be found in log file"
|
||||
|
||||
def test_log_file_is_not_empty(self):
|
||||
"""Test that the log file is not empty after logging."""
|
||||
# Log a message
|
||||
verbose_logger.info("Test message to ensure file is not empty")
|
||||
|
||||
# Read log file contents
|
||||
log_file_path = os.environ.get("LITELLM_LOG_FILE")
|
||||
assert log_file_path is not None, "LITELLM_LOG_FILE environment variable should be set"
|
||||
|
||||
log_contents = self.read_log_file_contents(log_file_path)
|
||||
|
||||
# Verify file is not empty
|
||||
assert len(log_contents.strip()) > 0, "Log file should not be empty after logging"
|
||||
|
||||
|
||||
class TestJSONLoggingBehavior:
|
||||
"""Test suite to verify JSON logging behavior for all LiteLLM loggers."""
|
||||
|
||||
def read_log_file_contents(self, log_file_path):
|
||||
"""Helper method to read and return contents of log file."""
|
||||
if not os.path.exists(log_file_path):
|
||||
return ""
|
||||
|
||||
with open(log_file_path, 'r') as f:
|
||||
return f.read()
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_json_logging(self, temp_log_file):
|
||||
"""Set up JSON logging environment and ensure proper isolation."""
|
||||
self.temp_log_path = temp_log_file
|
||||
|
||||
# Store original environment variables
|
||||
original_log_file = os.environ.get("LITELLM_LOG_FILE")
|
||||
original_json_logs = os.environ.get("JSON_LOGS")
|
||||
|
||||
# Set environment variables for JSON logging
|
||||
os.environ["LITELLM_LOG_FILE"] = temp_log_file
|
||||
os.environ["JSON_LOGS"] = "True"
|
||||
|
||||
# Force reload of the logging module to pick up new environment variables
|
||||
import importlib
|
||||
import litellm._logging
|
||||
importlib.reload(litellm._logging)
|
||||
|
||||
yield
|
||||
|
||||
# Cleanup: Restore original environment variables
|
||||
if original_log_file is not None:
|
||||
os.environ["LITELLM_LOG_FILE"] = original_log_file
|
||||
else:
|
||||
os.environ.pop("LITELLM_LOG_FILE", None)
|
||||
|
||||
if original_json_logs is not None:
|
||||
os.environ["JSON_LOGS"] = original_json_logs
|
||||
else:
|
||||
os.environ.pop("JSON_LOGS", None)
|
||||
|
||||
# Reload again to restore original state
|
||||
importlib.reload(litellm._logging)
|
||||
|
||||
def test_verbose_logger_json_info_level(self):
|
||||
"""Test that verbose_logger writes JSON formatted logs at INFO level."""
|
||||
test_message = "JSON INFO level test message from verbose_logger"
|
||||
|
||||
# Log at INFO level
|
||||
verbose_logger.info(test_message)
|
||||
|
||||
# Force flush all handlers to ensure they write to disk
|
||||
for handler in verbose_logger.handlers:
|
||||
if hasattr(handler, 'flush'):
|
||||
handler.flush()
|
||||
|
||||
# Read log file contents
|
||||
log_file_path = os.environ.get("LITELLM_LOG_FILE")
|
||||
assert log_file_path is not None, "LITELLM_LOG_FILE environment variable should be set"
|
||||
|
||||
log_contents = self.read_log_file_contents(log_file_path)
|
||||
assert log_contents.strip(), "Log file should not be empty"
|
||||
|
||||
# Parse JSON and verify structure
|
||||
log_lines = [line.strip() for line in log_contents.strip().split('\n') if line.strip()]
|
||||
assert len(log_lines) > 0, "Should have at least one log line"
|
||||
|
||||
# Find the line containing our test message
|
||||
target_log = None
|
||||
for line in log_lines:
|
||||
try:
|
||||
parsed = json.loads(line)
|
||||
if parsed.get("message") == test_message:
|
||||
target_log = parsed
|
||||
break
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
assert target_log is not None, f"Could not find JSON log entry with message: {test_message}"
|
||||
|
||||
# Verify JSON structure
|
||||
assert "message" in target_log, "JSON log should contain 'message' field"
|
||||
assert "level" in target_log, "JSON log should contain 'level' field"
|
||||
assert "timestamp" in target_log, "JSON log should contain 'timestamp' field"
|
||||
|
||||
# Verify content
|
||||
assert target_log["message"] == test_message
|
||||
assert target_log["level"] == "INFO"
|
||||
|
||||
# Verify timestamp is in ISO 8601 format
|
||||
timestamp_str = target_log["timestamp"]
|
||||
try:
|
||||
datetime.fromisoformat(timestamp_str)
|
||||
except ValueError:
|
||||
pytest.fail(f"Timestamp '{timestamp_str}' is not in valid ISO 8601 format")
|
||||
|
||||
def test_verbose_logger_json_debug_level(self):
|
||||
"""Test that verbose_logger writes JSON formatted logs at DEBUG level."""
|
||||
test_message = "JSON DEBUG level test message from verbose_logger"
|
||||
|
||||
# Log at DEBUG level
|
||||
verbose_logger.debug(test_message)
|
||||
|
||||
# Read log file contents
|
||||
log_file_path = os.environ.get("LITELLM_LOG_FILE")
|
||||
assert log_file_path is not None, "LITELLM_LOG_FILE environment variable should be set"
|
||||
|
||||
log_contents = self.read_log_file_contents(log_file_path)
|
||||
assert log_contents.strip(), "Log file should not be empty"
|
||||
|
||||
# Parse JSON and verify structure
|
||||
log_lines = [line.strip() for line in log_contents.strip().split('\n') if line.strip()]
|
||||
|
||||
# Find the line containing our test message
|
||||
target_log = None
|
||||
for line in log_lines:
|
||||
try:
|
||||
parsed = json.loads(line)
|
||||
if parsed.get("message") == test_message:
|
||||
target_log = parsed
|
||||
break
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
assert target_log is not None, f"Could not find JSON log entry with message: {test_message}"
|
||||
assert target_log["level"] == "DEBUG"
|
||||
|
||||
def test_verbose_proxy_logger_json_info_level(self):
|
||||
"""Test that verbose_proxy_logger writes JSON formatted logs at INFO level."""
|
||||
test_message = "JSON INFO level test message from verbose_proxy_logger"
|
||||
|
||||
# Log at INFO level
|
||||
verbose_proxy_logger.info(test_message)
|
||||
|
||||
# Read log file contents
|
||||
log_file_path = os.environ.get("LITELLM_LOG_FILE")
|
||||
assert log_file_path is not None, "LITELLM_LOG_FILE environment variable should be set"
|
||||
|
||||
log_contents = self.read_log_file_contents(log_file_path)
|
||||
assert log_contents.strip(), "Log file should not be empty"
|
||||
|
||||
# Parse JSON and verify structure
|
||||
log_lines = [line.strip() for line in log_contents.strip().split('\n') if line.strip()]
|
||||
|
||||
# Find the line containing our test message
|
||||
target_log = None
|
||||
for line in log_lines:
|
||||
try:
|
||||
parsed = json.loads(line)
|
||||
if parsed.get("message") == test_message:
|
||||
target_log = parsed
|
||||
break
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
assert target_log is not None, f"Could not find JSON log entry with message: {test_message}"
|
||||
|
||||
# Verify JSON structure and content
|
||||
assert target_log["message"] == test_message
|
||||
assert target_log["level"] == "INFO"
|
||||
|
||||
# Verify timestamp is in ISO 8601 format
|
||||
timestamp_str = target_log["timestamp"]
|
||||
try:
|
||||
datetime.fromisoformat(timestamp_str)
|
||||
except ValueError:
|
||||
pytest.fail(f"Timestamp '{timestamp_str}' is not in valid ISO 8601 format")
|
||||
|
||||
def test_verbose_proxy_logger_json_debug_level(self):
|
||||
"""Test that verbose_proxy_logger writes JSON formatted logs at DEBUG level."""
|
||||
test_message = "JSON DEBUG level test message from verbose_proxy_logger"
|
||||
|
||||
# Log at DEBUG level
|
||||
verbose_proxy_logger.debug(test_message)
|
||||
|
||||
# Read log file contents
|
||||
log_file_path = os.environ.get("LITELLM_LOG_FILE")
|
||||
assert log_file_path is not None, "LITELLM_LOG_FILE environment variable should be set"
|
||||
|
||||
log_contents = self.read_log_file_contents(log_file_path)
|
||||
assert log_contents.strip(), "Log file should not be empty"
|
||||
|
||||
# Parse JSON and verify structure
|
||||
log_lines = [line.strip() for line in log_contents.strip().split('\n') if line.strip()]
|
||||
|
||||
# Find the line containing our test message
|
||||
target_log = None
|
||||
for line in log_lines:
|
||||
try:
|
||||
parsed = json.loads(line)
|
||||
if parsed.get("message") == test_message:
|
||||
target_log = parsed
|
||||
break
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
assert target_log is not None, f"Could not find JSON log entry with message: {test_message}"
|
||||
assert target_log["level"] == "DEBUG"
|
||||
|
||||
def test_verbose_router_logger_json_info_level(self):
|
||||
"""Test that verbose_router_logger writes JSON formatted logs at INFO level."""
|
||||
test_message = "JSON INFO level test message from verbose_router_logger"
|
||||
|
||||
# Log at INFO level
|
||||
verbose_router_logger.info(test_message)
|
||||
|
||||
# Read log file contents
|
||||
log_file_path = os.environ.get("LITELLM_LOG_FILE")
|
||||
assert log_file_path is not None, "LITELLM_LOG_FILE environment variable should be set"
|
||||
|
||||
log_contents = self.read_log_file_contents(log_file_path)
|
||||
assert log_contents.strip(), "Log file should not be empty"
|
||||
|
||||
# Parse JSON and verify structure
|
||||
log_lines = [line.strip() for line in log_contents.strip().split('\n') if line.strip()]
|
||||
|
||||
# Find the line containing our test message
|
||||
target_log = None
|
||||
for line in log_lines:
|
||||
try:
|
||||
parsed = json.loads(line)
|
||||
if parsed.get("message") == test_message:
|
||||
target_log = parsed
|
||||
break
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
assert target_log is not None, f"Could not find JSON log entry with message: {test_message}"
|
||||
|
||||
# Verify JSON structure and content
|
||||
assert target_log["message"] == test_message
|
||||
assert target_log["level"] == "INFO"
|
||||
|
||||
# Verify timestamp is in ISO 8601 format
|
||||
timestamp_str = target_log["timestamp"]
|
||||
try:
|
||||
datetime.fromisoformat(timestamp_str)
|
||||
except ValueError:
|
||||
pytest.fail(f"Timestamp '{timestamp_str}' is not in valid ISO 8601 format")
|
||||
|
||||
def test_verbose_router_logger_json_debug_level(self):
|
||||
"""Test that verbose_router_logger writes JSON formatted logs at DEBUG level."""
|
||||
test_message = "JSON DEBUG level test message from verbose_router_logger"
|
||||
|
||||
# Log at DEBUG level
|
||||
verbose_router_logger.debug(test_message)
|
||||
|
||||
# Read log file contents
|
||||
log_file_path = os.environ.get("LITELLM_LOG_FILE")
|
||||
assert log_file_path is not None, "LITELLM_LOG_FILE environment variable should be set"
|
||||
|
||||
log_contents = self.read_log_file_contents(log_file_path)
|
||||
assert log_contents.strip(), "Log file should not be empty"
|
||||
|
||||
# Parse JSON and verify structure
|
||||
log_lines = [line.strip() for line in log_contents.strip().split('\n') if line.strip()]
|
||||
|
||||
# Find the line containing our test message
|
||||
target_log = None
|
||||
for line in log_lines:
|
||||
try:
|
||||
parsed = json.loads(line)
|
||||
if parsed.get("message") == test_message:
|
||||
target_log = parsed
|
||||
break
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
assert target_log is not None, f"Could not find JSON log entry with message: {test_message}"
|
||||
assert target_log["level"] == "DEBUG"
|
||||
|
||||
def test_json_output_is_valid_json(self):
|
||||
"""Test that all JSON log output can be parsed as valid JSON."""
|
||||
test_messages = [
|
||||
"JSON test message 1",
|
||||
"JSON test message 2",
|
||||
"JSON test message 3"
|
||||
]
|
||||
|
||||
# Log messages from all loggers
|
||||
verbose_logger.info(test_messages[0])
|
||||
verbose_proxy_logger.info(test_messages[1])
|
||||
verbose_router_logger.info(test_messages[2])
|
||||
|
||||
# Read log file contents
|
||||
log_file_path = os.environ.get("LITELLM_LOG_FILE")
|
||||
assert log_file_path is not None, "LITELLM_LOG_FILE environment variable should be set"
|
||||
|
||||
log_contents = self.read_log_file_contents(log_file_path)
|
||||
assert log_contents.strip(), "Log file should not be empty"
|
||||
|
||||
# Parse each line as JSON
|
||||
log_lines = [line.strip() for line in log_contents.strip().split('\n') if line.strip()]
|
||||
parsed_logs = []
|
||||
|
||||
for line in log_lines:
|
||||
try:
|
||||
parsed = json.loads(line)
|
||||
parsed_logs.append(parsed)
|
||||
except json.JSONDecodeError as e:
|
||||
pytest.fail(f"Failed to parse JSON log line: {line}. Error: {e}")
|
||||
|
||||
assert len(parsed_logs) >= len(test_messages), f"Should have at least {len(test_messages)} parsed log entries"
|
||||
|
||||
# Verify each parsed log has required fields
|
||||
for parsed_log in parsed_logs:
|
||||
assert isinstance(parsed_log, dict), "Parsed log should be a dictionary"
|
||||
assert "message" in parsed_log, "Each log should have a 'message' field"
|
||||
assert "level" in parsed_log, "Each log should have a 'level' field"
|
||||
assert "timestamp" in parsed_log, "Each log should have a 'timestamp' field"
|
||||
|
||||
def test_json_timestamp_iso8601_format(self):
|
||||
"""Test that JSON log timestamps are in ISO 8601 format."""
|
||||
test_message = "Timestamp format test message"
|
||||
|
||||
# Log a message
|
||||
verbose_logger.info(test_message)
|
||||
|
||||
# Read log file contents
|
||||
log_file_path = os.environ.get("LITELLM_LOG_FILE")
|
||||
assert log_file_path is not None, "LITELLM_LOG_FILE environment variable should be set"
|
||||
|
||||
log_contents = self.read_log_file_contents(log_file_path)
|
||||
assert log_contents.strip(), "Log file should not be empty"
|
||||
|
||||
# Parse JSON and verify timestamp format
|
||||
log_lines = [line.strip() for line in log_contents.strip().split('\n') if line.strip()]
|
||||
|
||||
# Find the line containing our test message
|
||||
target_log = None
|
||||
for line in log_lines:
|
||||
try:
|
||||
parsed = json.loads(line)
|
||||
if parsed.get("message") == test_message:
|
||||
target_log = parsed
|
||||
break
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
assert target_log is not None, f"Could not find JSON log entry with message: {test_message}"
|
||||
|
||||
timestamp_str = target_log["timestamp"]
|
||||
|
||||
# Verify timestamp can be parsed as ISO 8601
|
||||
try:
|
||||
parsed_timestamp = datetime.fromisoformat(timestamp_str)
|
||||
assert isinstance(parsed_timestamp, datetime), "Parsed timestamp should be a datetime object"
|
||||
except ValueError as e:
|
||||
pytest.fail(f"Timestamp '{timestamp_str}' is not in valid ISO 8601 format. Error: {e}")
|
||||
|
||||
# Verify timestamp format matches expected pattern (YYYY-MM-DDTHH:MM:SS.ffffff)
|
||||
import re
|
||||
iso8601_pattern = r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?$'
|
||||
assert re.match(iso8601_pattern, timestamp_str), f"Timestamp '{timestamp_str}' does not match ISO 8601 pattern"
|
||||
|
||||
def test_json_logs_contain_expected_fields(self):
|
||||
"""Test that JSON logs contain all expected fields with correct types."""
|
||||
test_message = "Field validation test message"
|
||||
|
||||
# Log a message
|
||||
verbose_logger.info(test_message)
|
||||
|
||||
# Read log file contents
|
||||
log_file_path = os.environ.get("LITELLM_LOG_FILE")
|
||||
assert log_file_path is not None, "LITELLM_LOG_FILE environment variable should be set"
|
||||
|
||||
log_contents = self.read_log_file_contents(log_file_path)
|
||||
assert log_contents.strip(), "Log file should not be empty"
|
||||
|
||||
# Parse JSON and verify fields
|
||||
log_lines = [line.strip() for line in log_contents.strip().split('\n') if line.strip()]
|
||||
|
||||
# Find the line containing our test message
|
||||
target_log = None
|
||||
for line in log_lines:
|
||||
try:
|
||||
parsed = json.loads(line)
|
||||
if parsed.get("message") == test_message:
|
||||
target_log = parsed
|
||||
break
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
assert target_log is not None, f"Could not find JSON log entry with message: {test_message}"
|
||||
|
||||
# Verify required fields exist and have correct types
|
||||
assert "message" in target_log, "JSON log should contain 'message' field"
|
||||
assert "level" in target_log, "JSON log should contain 'level' field"
|
||||
assert "timestamp" in target_log, "JSON log should contain 'timestamp' field"
|
||||
|
||||
assert isinstance(target_log["message"], str), "'message' field should be a string"
|
||||
assert isinstance(target_log["level"], str), "'level' field should be a string"
|
||||
assert isinstance(target_log["timestamp"], str), "'timestamp' field should be a string"
|
||||
|
||||
# Verify field values
|
||||
assert target_log["message"] == test_message
|
||||
assert target_log["level"] in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], "Level should be a valid log level"
|
||||
|
||||
def test_multiple_json_loggers_write_to_same_file(self):
|
||||
"""Test that all loggers write JSON formatted logs to the same file."""
|
||||
messages = {
|
||||
'verbose_logger': "JSON message from verbose_logger",
|
||||
'verbose_proxy_logger': "JSON message from verbose_proxy_logger",
|
||||
'verbose_router_logger': "JSON message from verbose_router_logger"
|
||||
}
|
||||
|
||||
# Log messages from different loggers
|
||||
verbose_logger.info(messages['verbose_logger'])
|
||||
verbose_proxy_logger.info(messages['verbose_proxy_logger'])
|
||||
verbose_router_logger.info(messages['verbose_router_logger'])
|
||||
|
||||
# Read log file contents
|
||||
log_file_path = os.environ.get("LITELLM_LOG_FILE")
|
||||
assert log_file_path is not None, "LITELLM_LOG_FILE environment variable should be set"
|
||||
|
||||
log_contents = self.read_log_file_contents(log_file_path)
|
||||
assert log_contents.strip(), "Log file should not be empty"
|
||||
|
||||
# Parse all JSON logs
|
||||
log_lines = [line.strip() for line in log_contents.strip().split('\n') if line.strip()]
|
||||
parsed_logs = []
|
||||
|
||||
for line in log_lines:
|
||||
try:
|
||||
parsed = json.loads(line)
|
||||
parsed_logs.append(parsed)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
# Find logs for each message
|
||||
found_messages = set()
|
||||
for parsed_log in parsed_logs:
|
||||
message = parsed_log.get("message", "")
|
||||
if message in messages.values():
|
||||
found_messages.add(message)
|
||||
|
||||
# Verify all messages are found in JSON format
|
||||
for message in messages.values():
|
||||
assert message in found_messages, f"Message '{message}' should be found in JSON logs"
|
||||
@@ -957,7 +957,12 @@ def test_get_model_info_shows_supports_computer_use():
|
||||
def test_pre_process_non_default_params(model, custom_llm_provider):
|
||||
from pydantic import BaseModel
|
||||
|
||||
from litellm.utils import pre_process_non_default_params
|
||||
from litellm.utils import ProviderConfigManager, pre_process_non_default_params
|
||||
|
||||
provider_config = ProviderConfigManager.get_provider_chat_config(
|
||||
model=model,
|
||||
provider=LlmProviders(custom_llm_provider)
|
||||
)
|
||||
|
||||
class ResponseFormat(BaseModel):
|
||||
x: str
|
||||
@@ -974,6 +979,7 @@ def test_pre_process_non_default_params(model, custom_llm_provider):
|
||||
special_params=special_params,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
additional_drop_params=None,
|
||||
provider_config=provider_config,
|
||||
)
|
||||
print(processed_non_default_params)
|
||||
assert processed_non_default_params == {
|
||||
|
||||
Reference in New Issue
Block a user