mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
Merge remote-tracking branch 'origin' into litellm_user_promethus_metrics
This commit is contained in:
commit
1e314ce203
97 changed files with 7104 additions and 1036 deletions
1
.github/workflows/publish-migrations.yml
vendored
1
.github/workflows/publish-migrations.yml
vendored
|
|
@ -13,6 +13,7 @@ on:
|
|||
|
||||
jobs:
|
||||
publish-migrations:
|
||||
if: github.repository == 'BerriAI/litellm'
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
|
|
|
|||
|
|
@ -262,6 +262,7 @@ Support for more providers. Missing a provider or LLM Platform, raise a [feature
|
|||
|
||||
| Provider | `/chat/completions` | `/messages` | `/responses` | `/embeddings` | `/image/generations` | `/audio/transcriptions` | `/audio/speech` | `/moderations` | `/batches` | `/rerank` |
|
||||
|-------------------------------------------------------------------------------------|---------------------|-------------|--------------|---------------|----------------------|-------------------------|-----------------|----------------|-----------|-----------|
|
||||
| [Abliteration (`abliteration`)](https://docs.litellm.ai/docs/providers/abliteration) | ✅ | | | | | | | | | |
|
||||
| [AI/ML API (`aiml`)](https://docs.litellm.ai/docs/providers/aiml) | ✅ | ✅ | ✅ | ✅ | ✅ | | | | | |
|
||||
| [AI21 (`ai21`)](https://docs.litellm.ai/docs/providers/ai21) | ✅ | ✅ | ✅ | | | | | | | |
|
||||
| [AI21 Chat (`ai21_chat`)](https://docs.litellm.ai/docs/providers/ai21) | ✅ | ✅ | ✅ | | | | | | | |
|
||||
|
|
@ -455,4 +456,3 @@ All these checks must pass before your PR can be merged.
|
|||
<img src="https://contrib.rocks/image?repo=BerriAI/litellm" />
|
||||
</a>
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -649,3 +649,16 @@ general_settings:
|
|||
```
|
||||
|
||||
This is useful when you want discoverability for MCP offerings without granting additional execution privileges.
|
||||
|
||||
|
||||
## Publish MCP Registry
|
||||
|
||||
If you want other systems—for example external agent frameworks such as MCP-capable IDEs running outside your network—to automatically discover the MCP servers hosted on LiteLLM, you can expose a Model Context Protocol Registry endpoint. This registry lists the built-in LiteLLM MCP server and every server you have configured, using the [official MCP Registry spec](https://github.com/modelcontextprotocol/registry).
|
||||
|
||||
1. Set `enable_mcp_registry: true` under `general_settings` in your proxy config (or DB settings) and restart the proxy.
|
||||
2. LiteLLM will serve the registry at `GET /v1/mcp/registry.json`.
|
||||
3. Each entry points to either `/mcp` (built-in server) or `/{mcp_server_name}/mcp` for your custom servers, so clients can connect directly using the advertised Streamable HTTP URL.
|
||||
|
||||
:::note Permissions still apply
|
||||
The registry only advertises server URLs. Actual access control is still enforced by LiteLLM when the client connects to `/mcp` or `/{server}/mcp`, so publishing the registry does not bypass per-key permissions.
|
||||
:::
|
||||
|
|
|
|||
93
docs/my-website/docs/observability/focus.md
Normal file
93
docs/my-website/docs/observability/focus.md
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
|
||||
# Focus Export (Experimental)
|
||||
|
||||
:::caution Experimental feature
|
||||
Focus Format export is under active development and currently considered experimental.
|
||||
Interfaces, schema mappings, and configuration options may change as we iterate based on user feedback.
|
||||
Please treat this integration as a preview and report any issues or suggestions to help us stabilize and improve the workflow.
|
||||
:::
|
||||
|
||||
LiteLLM can emit usage data in the [FinOps FOCUS format](https://focus.finops.org/focus-specification/v1-2/) and push artifacts (for example Parquet files) to destinations such as Amazon S3. This enables downstream cost-analysis tooling to ingest a standardised dataset directly from LiteLLM.
|
||||
|
||||
LiteLLM currently conforms to the FinOps FOCUS v1.2 specification when emitting this dataset.
|
||||
|
||||
## Overview
|
||||
|
||||
| Property | Details |
|
||||
|----------|---------|
|
||||
| Destination | Export LiteLLM usage data in FOCUS format to managed storage (currently S3) |
|
||||
| Callback name | `focus` |
|
||||
| Supported operations | Automatic scheduled export |
|
||||
| Data format | FOCUS Normalised Dataset (Parquet) |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Common settings
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `FOCUS_PROVIDER` | No | Destination provider (defaults to `s3`). |
|
||||
| `FOCUS_FORMAT` | No | Output format (currently only `parquet`). |
|
||||
| `FOCUS_FREQUENCY` | No | Export cadence. Prefer `hourly` or `daily` for production; `interval` is intended for short test loops. Defaults to `hourly`. |
|
||||
| `FOCUS_CRON_OFFSET` | No | Minute offset used for hourly/daily cron triggers. Defaults to `5`. |
|
||||
| `FOCUS_INTERVAL_SECONDS` | No | Interval (seconds) when `FOCUS_FREQUENCY="interval"`. |
|
||||
| `FOCUS_PREFIX` | No | Object key prefix/folder. Defaults to `focus_exports`. |
|
||||
|
||||
### S3 destination
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `FOCUS_S3_BUCKET_NAME` | Yes | Destination bucket for exported files. |
|
||||
| `FOCUS_S3_REGION_NAME` | No | AWS region for the bucket. |
|
||||
| `FOCUS_S3_ENDPOINT_URL` | No | Custom endpoint (useful for S3-compatible storage). |
|
||||
| `FOCUS_S3_ACCESS_KEY` | Yes | AWS access key for uploads. |
|
||||
| `FOCUS_S3_SECRET_KEY` | Yes | AWS secret key for uploads. |
|
||||
| `FOCUS_S3_SESSION_TOKEN` | No | AWS session token if using temporary credentials. |
|
||||
|
||||
## Setup via Config
|
||||
|
||||
### Configure environment variables
|
||||
|
||||
```bash
|
||||
export FOCUS_PROVIDER="s3"
|
||||
export FOCUS_PREFIX="focus_exports"
|
||||
|
||||
# S3 example
|
||||
export FOCUS_S3_BUCKET_NAME="my-litellm-focus-bucket"
|
||||
export FOCUS_S3_REGION_NAME="us-east-1"
|
||||
export FOCUS_S3_ACCESS_KEY="AKIA..."
|
||||
export FOCUS_S3_SECRET_KEY="..."
|
||||
```
|
||||
|
||||
### Update LiteLLM config
|
||||
|
||||
```yaml
|
||||
model_list:
|
||||
- model_name: gpt-4o
|
||||
litellm_params:
|
||||
model: openai/gpt-4o
|
||||
api_key: sk-your-key
|
||||
|
||||
litellm_settings:
|
||||
callbacks: ["focus"]
|
||||
```
|
||||
|
||||
### Start the proxy
|
||||
|
||||
```bash
|
||||
litellm --config /path/to/config.yaml
|
||||
```
|
||||
|
||||
During boot LiteLLM registers the Focus logger and a background job that runs according to the configured frequency.
|
||||
|
||||
## Planned Enhancements
|
||||
- Add "Setup on UI" flow alongside the current configuration-based setup.
|
||||
- Add GCS / Azure Blob to the Destination options.
|
||||
- Support CSV output alongside Parquet.
|
||||
|
||||
## Related Links
|
||||
|
||||
- [Focus](https://focus.finops.org/)
|
||||
|
||||
122
docs/my-website/docs/observability/qualifire_integration.md
Normal file
122
docs/my-website/docs/observability/qualifire_integration.md
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import Image from '@theme/IdealImage';
|
||||
|
||||
# Qualifire - LLM Evaluation, Guardrails & Observability
|
||||
|
||||
[Qualifire](https://qualifire.ai/) provides real-time Agentic evaluations, guardrails and observability for production AI applications.
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- **Evaluation** - Systematically assess AI behavior to detect hallucinations, jailbreaks, policy breaches, and other vulnerabilities
|
||||
- **Guardrails** - Real-time interventions to prevent risks like brand damage, data leaks, and compliance breaches
|
||||
- **Observability** - Complete tracing and logging for RAG pipelines, chatbots, and AI agents
|
||||
- **Prompt Management** - Centralized prompt management with versioning and no-code studio
|
||||
|
||||
:::tip
|
||||
|
||||
Looking for Qualifire Guardrails? Check out the [Qualifire Guardrails Integration](../proxy/guardrails/qualifire.md) for real-time content moderation, prompt injection detection, PII checks, and more.
|
||||
|
||||
:::
|
||||
|
||||
## Pre-Requisites
|
||||
|
||||
1. Create an account on [Qualifire](https://app.qualifire.ai/)
|
||||
2. Get your API key and webhook URL from the Qualifire dashboard
|
||||
|
||||
```bash
|
||||
pip install litellm
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
Use just 2 lines of code to instantly log your responses **across all providers** with Qualifire.
|
||||
|
||||
```python
|
||||
litellm.callbacks = ["qualifire_eval"]
|
||||
```
|
||||
|
||||
```python
|
||||
import litellm
|
||||
import os
|
||||
|
||||
# Set Qualifire credentials
|
||||
os.environ["QUALIFIRE_API_KEY"] = "your-qualifire-api-key"
|
||||
os.environ["QUALIFIRE_WEBHOOK_URL"] = "https://your-qualifire-webhook-url"
|
||||
|
||||
# LLM API Keys
|
||||
os.environ['OPENAI_API_KEY'] = "your-openai-api-key"
|
||||
|
||||
# Set qualifire_eval as a callback & LiteLLM will send the data to Qualifire
|
||||
litellm.callbacks = ["qualifire_eval"]
|
||||
|
||||
# OpenAI call
|
||||
response = litellm.completion(
|
||||
model="gpt-5",
|
||||
messages=[
|
||||
{"role": "user", "content": "Hi 👋 - i'm openai"}
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
## Using with LiteLLM Proxy
|
||||
|
||||
1. Setup config.yaml
|
||||
|
||||
```yaml
|
||||
model_list:
|
||||
- model_name: gpt-4o
|
||||
litellm_params:
|
||||
model: openai/gpt-4o
|
||||
api_key: os.environ/OPENAI_API_KEY
|
||||
|
||||
litellm_settings:
|
||||
callbacks: ["qualifire_eval"]
|
||||
|
||||
general_settings:
|
||||
master_key: "sk-1234"
|
||||
|
||||
environment_variables:
|
||||
QUALIFIRE_API_KEY: "your-qualifire-api-key"
|
||||
QUALIFIRE_WEBHOOK_URL: "https://app.qualifire.ai/api/v1/webhooks/evaluations"
|
||||
```
|
||||
|
||||
2. Start the proxy
|
||||
|
||||
```bash
|
||||
litellm --config config.yaml
|
||||
```
|
||||
|
||||
3. Test it!
|
||||
|
||||
```bash
|
||||
curl -X POST 'http://0.0.0.0:4000/chat/completions' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Bearer sk-1234' \
|
||||
-d '{ "model": "gpt-4o", "messages": [{"role": "user", "content": "Hi 👋 - i'm openai"}]}'
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
| ----------------------- | ------------------------------------------------------ |
|
||||
| `QUALIFIRE_API_KEY` | Your Qualifire API key for authentication |
|
||||
| `QUALIFIRE_WEBHOOK_URL` | The Qualifire webhook endpoint URL from your dashboard |
|
||||
|
||||
## What Gets Logged?
|
||||
|
||||
The [LiteLLM Standard Logging Payload](https://docs.litellm.ai/docs/proxy/logging_spec) is sent to your Qualifire endpoint on each successful LLM API call.
|
||||
|
||||
This includes:
|
||||
|
||||
- Request messages and parameters
|
||||
- Response content and metadata
|
||||
- Token usage statistics
|
||||
- Latency metrics
|
||||
- Model information
|
||||
- Cost data
|
||||
|
||||
Once data is in Qualifire, you can:
|
||||
|
||||
- Run evaluations to detect hallucinations, toxicity, and policy violations
|
||||
- Set up guardrails to block or modify responses in real-time
|
||||
- View traces across your entire AI pipeline
|
||||
- Track performance and quality metrics over time
|
||||
109
docs/my-website/docs/providers/abliteration.md
Normal file
109
docs/my-website/docs/providers/abliteration.md
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
# Abliteration
|
||||
|
||||
## Overview
|
||||
|
||||
| Property | Details |
|
||||
|-------|-------|
|
||||
| Description | Abliteration provides an OpenAI-compatible `/chat/completions` endpoint. |
|
||||
| Provider Route on LiteLLM | `abliteration/` |
|
||||
| Link to Provider Doc | [Abliteration](https://abliteration.ai) |
|
||||
| Base URL | `https://api.abliteration.ai/v1` |
|
||||
| Supported Operations | [`/chat/completions`](#sample-usage) |
|
||||
|
||||
<br />
|
||||
|
||||
## Required Variables
|
||||
|
||||
```python showLineNumbers title="Environment Variables"
|
||||
os.environ["ABLITERATION_API_KEY"] = "" # your Abliteration API key
|
||||
```
|
||||
|
||||
## Sample Usage
|
||||
|
||||
```python showLineNumbers title="Abliteration Completion"
|
||||
import os
|
||||
from litellm import completion
|
||||
|
||||
os.environ["ABLITERATION_API_KEY"] = ""
|
||||
|
||||
response = completion(
|
||||
model="abliteration/abliterated-model",
|
||||
messages=[{"role": "user", "content": "Hello from LiteLLM"}],
|
||||
)
|
||||
|
||||
print(response)
|
||||
```
|
||||
|
||||
## Sample Usage - Streaming
|
||||
|
||||
```python showLineNumbers title="Abliteration Streaming Completion"
|
||||
import os
|
||||
from litellm import completion
|
||||
|
||||
os.environ["ABLITERATION_API_KEY"] = ""
|
||||
|
||||
response = completion(
|
||||
model="abliteration/abliterated-model",
|
||||
messages=[{"role": "user", "content": "Stream a short reply"}],
|
||||
stream=True,
|
||||
)
|
||||
|
||||
for chunk in response:
|
||||
print(chunk)
|
||||
```
|
||||
|
||||
## Usage with LiteLLM Proxy Server
|
||||
|
||||
1. Add the model to your proxy config:
|
||||
|
||||
```yaml showLineNumbers title="config.yaml"
|
||||
model_list:
|
||||
- model_name: abliteration-chat
|
||||
litellm_params:
|
||||
model: abliteration/abliterated-model
|
||||
api_key: os.environ/ABLITERATION_API_KEY
|
||||
```
|
||||
|
||||
2. Start the proxy:
|
||||
|
||||
```bash
|
||||
litellm --config /path/to/config.yaml
|
||||
```
|
||||
|
||||
## Direct API Usage (Bearer Token)
|
||||
|
||||
Use the environment variable as a Bearer token against the OpenAI-compatible endpoint:
|
||||
`https://api.abliteration.ai/v1/chat/completions`.
|
||||
|
||||
```bash showLineNumbers title="cURL"
|
||||
export ABLITERATION_API_KEY=""
|
||||
curl https://api.abliteration.ai/v1/chat/completions \
|
||||
-H "Authorization: Bearer ${ABLITERATION_API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "abliterated-model",
|
||||
"messages": [{"role": "user", "content": "Hello from Abliteration"}]
|
||||
}'
|
||||
```
|
||||
|
||||
```python showLineNumbers title="Python (requests)"
|
||||
import os
|
||||
import requests
|
||||
|
||||
api_key = os.environ["ABLITERATION_API_KEY"]
|
||||
|
||||
response = requests.post(
|
||||
"https://api.abliteration.ai/v1/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": "abliterated-model",
|
||||
"messages": [{"role": "user", "content": "Hello from Abliteration"}],
|
||||
},
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
print(response.json())
|
||||
```
|
||||
|
|
@ -35,6 +35,8 @@ import json
|
|||
# !gcloud auth application-default login - run this to add vertex credentials to your env
|
||||
## OR ##
|
||||
file_path = 'path/to/vertex_ai_service_account.json'
|
||||
## OR ##
|
||||
export VERTEXAI_API_KEY="your-api-key"
|
||||
|
||||
# Load the JSON file
|
||||
with open(file_path, 'r') as file:
|
||||
|
|
@ -47,7 +49,7 @@ vertex_credentials_json = json.dumps(vertex_credentials)
|
|||
response = completion(
|
||||
model="vertex_ai/gemini-2.5-pro",
|
||||
messages=[{ "content": "Hello, how are you?","role": "user"}],
|
||||
vertex_credentials=vertex_credentials_json
|
||||
vertex_credentials=vertex_credentials_json # Can remove this is added VERTEXAI_API_KEY in env
|
||||
)
|
||||
```
|
||||
|
||||
|
|
@ -1329,15 +1331,41 @@ Here's how to use Vertex AI with the LiteLLM Proxy Server
|
|||
|
||||
## Authentication - vertex_project, vertex_location, etc.
|
||||
|
||||
LiteLLM supports two authentication methods for Vertex AI:
|
||||
|
||||
1. **API Key Authentication** (Recommended for getting started)
|
||||
2. **Service Account Credentials** (Recommended for production)
|
||||
|
||||
Set your vertex credentials via:
|
||||
- dynamic params
|
||||
OR
|
||||
- env vars
|
||||
|
||||
### **Authentication Method 1:
|
||||
|
||||
### **Dynamic Params**
|
||||
The simplest way to authenticate with Vertex AI. You can set:
|
||||
- `api_key` (str) - Your Vertex AI API key
|
||||
|
||||
You can set:
|
||||
**Environment Variables:**
|
||||
```bash
|
||||
export VERTEXAI_API_KEY="your-api-key"
|
||||
```
|
||||
|
||||
**Or pass as parameters:**
|
||||
```python
|
||||
from litellm import completion
|
||||
|
||||
response = completion(
|
||||
model="vertex_ai/gemini-2.0-flash-exp",
|
||||
messages=[{"role": "user", "content": "Hello!"}],
|
||||
api_key="your-vertex-api-key",
|
||||
|
||||
)
|
||||
```
|
||||
|
||||
### **Authentication Method 2: Service Account Credentials**
|
||||
|
||||
For production environments with fine-grained access control. You can set:
|
||||
- `vertex_credentials` (str) - can be a json string or filepath to your vertex ai service account.json
|
||||
- `vertex_location` (str) - place where vertex model is deployed (us-central1, asia-southeast1, etc.). Some models support the global location, please see [Vertex AI documentation](https://cloud.google.com/vertex-ai/generative-ai/docs/learn/locations#supported_models)
|
||||
- `vertex_project` Optional[str] - use if vertex project different from the one in vertex_credentials
|
||||
|
|
@ -1392,7 +1420,16 @@ model_list:
|
|||
|
||||
### **Environment Variables**
|
||||
|
||||
You can set:
|
||||
#### For API Key Authentication:
|
||||
|
||||
- `VERTEXAI_API_KEY` or `VERTEX_API_KEY` - Your Vertex AI API key
|
||||
|
||||
```bash
|
||||
export VERTEXAI_API_KEY="your-vertex-api-key"
|
||||
```
|
||||
|
||||
#### For Service Account Authentication:
|
||||
|
||||
- `GOOGLE_APPLICATION_CREDENTIALS` - store the filepath for your service_account.json in here (used by vertex sdk directly).
|
||||
- VERTEXAI_LOCATION - place where vertex model is deployed (us-central1, asia-southeast1, etc.)
|
||||
- VERTEXAI_PROJECT - Optional[str] - use if vertex project different from the one in vertex_credentials
|
||||
|
|
|
|||
|
|
@ -581,6 +581,18 @@ router_settings:
|
|||
| FIREWORKS_AI_56_B_MOE | Size parameter for Fireworks AI 56B MOE model. Default is 56
|
||||
| FIREWORKS_AI_80_B | Size parameter for Fireworks AI 80B model. Default is 80
|
||||
| FIREWORKS_AI_176_B_MOE | Size parameter for Fireworks AI 176B MOE model. Default is 176
|
||||
| FOCUS_PROVIDER | Destination provider for Focus exports (e.g., `s3`). Defaults to `s3`.
|
||||
| FOCUS_FORMAT | Output format for Focus exports. Defaults to `parquet`.
|
||||
| FOCUS_FREQUENCY | Frequency for scheduled Focus exports (`hourly`, `daily`, or `interval`). Defaults to `hourly`.
|
||||
| FOCUS_CRON_OFFSET | Minute offset used when scheduling hourly/daily Focus exports. Defaults to `5` minutes.
|
||||
| FOCUS_INTERVAL_SECONDS | Interval (in seconds) for Focus exports when `frequency` is `interval`.
|
||||
| FOCUS_PREFIX | Object key prefix (or folder) used when uploading Focus export files. Defaults to `focus_exports`.
|
||||
| FOCUS_S3_BUCKET_NAME | S3 bucket to upload Focus export files when using the S3 destination.
|
||||
| FOCUS_S3_REGION_NAME | AWS region for the Focus export S3 bucket.
|
||||
| FOCUS_S3_ENDPOINT_URL | Custom endpoint for the Focus export S3 client (optional; useful for S3-compatible storage).
|
||||
| FOCUS_S3_ACCESS_KEY | AWS access key ID used by the Focus export S3 client.
|
||||
| FOCUS_S3_SECRET_KEY | AWS secret access key used by the Focus export S3 client.
|
||||
| FOCUS_S3_SESSION_TOKEN | AWS session token used by the Focus export S3 client (optional).
|
||||
| FUNCTION_DEFINITION_TOKEN_COUNT | Token count for function definitions. Default is 9
|
||||
| GALILEO_BASE_URL | Base URL for Galileo platform
|
||||
| GALILEO_PASSWORD | Password for Galileo authentication
|
||||
|
|
|
|||
|
|
@ -8,13 +8,7 @@ Use [Qualifire](https://qualifire.ai) to evaluate LLM outputs for quality, safet
|
|||
|
||||
## Quick Start
|
||||
|
||||
### 1. Install the Qualifire SDK
|
||||
|
||||
```bash
|
||||
pip install qualifire
|
||||
```
|
||||
|
||||
### 2. Define Guardrails on your LiteLLM config.yaml
|
||||
### 1. Define Guardrails on your LiteLLM config.yaml
|
||||
|
||||
Define your guardrails under the `guardrails` section:
|
||||
|
||||
|
|
@ -61,13 +55,13 @@ guardrails:
|
|||
- `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 as LLM call. Response not returned until guardrail check completes
|
||||
|
||||
### 3. Start LiteLLM Gateway
|
||||
### 2. Start LiteLLM Gateway
|
||||
|
||||
```shell
|
||||
litellm --config config.yaml --detailed_debug
|
||||
```
|
||||
|
||||
### 4. Test request
|
||||
### 3. Test request
|
||||
|
||||
**[Langchain, OpenAI SDK Usage Examples](../proxy/user_keys#request-format)**
|
||||
|
||||
|
|
@ -142,7 +136,7 @@ guardrails:
|
|||
evaluation_id: eval_abc123 # Your evaluation ID from Qualifire dashboard
|
||||
```
|
||||
|
||||
When `evaluation_id` is provided, LiteLLM will use `invoke_evaluation()` instead of `evaluate()`, running the pre-configured evaluation from your dashboard.
|
||||
When `evaluation_id` is provided, LiteLLM will use the invoke evaluation API endpoint instead of the evaluate endpoint, running the pre-configured evaluation from your dashboard.
|
||||
|
||||
## Available Checks
|
||||
|
||||
|
|
@ -213,19 +207,19 @@ guardrails:
|
|||
|
||||
### Parameter Reference
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
| ------------------------------ | ----------- | --------------------------- | -------------------------------------------------------- |
|
||||
| `api_key` | `str` | `QUALIFIRE_API_KEY` env var | Your Qualifire API key |
|
||||
| `api_base` | `str` | `None` | Custom API base URL (optional) |
|
||||
| `evaluation_id` | `str` | `None` | Pre-configured evaluation ID from Qualifire dashboard |
|
||||
| `prompt_injections` | `bool` | `true` (if no other checks) | Enable prompt injection detection |
|
||||
| `hallucinations_check` | `bool` | `None` | Enable hallucination detection |
|
||||
| `grounding_check` | `bool` | `None` | Enable grounding verification |
|
||||
| `pii_check` | `bool` | `None` | Enable PII detection |
|
||||
| `content_moderation_check` | `bool` | `None` | Enable content moderation |
|
||||
| `tool_selection_quality_check` | `bool` | `None` | Enable tool selection quality check |
|
||||
| `assertions` | `List[str]` | `None` | Custom assertions to validate |
|
||||
| `on_flagged` | `str` | `"block"` | Action when content is flagged: `"block"` or `"monitor"` |
|
||||
| Parameter | Type | Default | Description |
|
||||
| ------------------------------ | ----------- | ---------------------------- | -------------------------------------------------------- |
|
||||
| `api_key` | `str` | `QUALIFIRE_API_KEY` env var | Your Qualifire API key |
|
||||
| `api_base` | `str` | `https://proxy.qualifire.ai` | Custom API base URL (optional) |
|
||||
| `evaluation_id` | `str` | `None` | Pre-configured evaluation ID from Qualifire dashboard |
|
||||
| `prompt_injections` | `bool` | `true` (if no other checks) | Enable prompt injection detection |
|
||||
| `hallucinations_check` | `bool` | `None` | Enable hallucination detection |
|
||||
| `grounding_check` | `bool` | `None` | Enable grounding verification |
|
||||
| `pii_check` | `bool` | `None` | Enable PII detection |
|
||||
| `content_moderation_check` | `bool` | `None` | Enable content moderation |
|
||||
| `tool_selection_quality_check` | `bool` | `None` | Enable tool selection quality check |
|
||||
| `assertions` | `List[str]` | `None` | Custom assertions to validate |
|
||||
| `on_flagged` | `str` | `"block"` | Action when content is flagged: `"block"` or `"monitor"` |
|
||||
|
||||
### Default Behavior
|
||||
|
||||
|
|
@ -261,4 +255,3 @@ This evaluates whether the LLM selected the appropriate tools and provided corre
|
|||
|
||||
- [Qualifire Documentation](https://docs.qualifire.ai)
|
||||
- [Qualifire Dashboard](https://app.qualifire.ai)
|
||||
- [Qualifire Python SDK](https://github.com/qualifire-dev/qualifire-python-sdk)
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ const sidebars = {
|
|||
"proxy/guardrails/test_playground",
|
||||
"proxy/guardrails/litellm_content_filter",
|
||||
...[
|
||||
"proxy/guardrails/qualifire",
|
||||
"proxy/guardrails/aim_security",
|
||||
"proxy/guardrails/onyx_security",
|
||||
"proxy/guardrails/aporia_api",
|
||||
|
|
@ -653,12 +654,13 @@ const sidebars = {
|
|||
"providers/bedrock_writer",
|
||||
"providers/bedrock_batches",
|
||||
"providers/aws_polly",
|
||||
"providers/bedrock_vector_store",
|
||||
]
|
||||
},
|
||||
"providers/litellm_proxy",
|
||||
"providers/ai21",
|
||||
"providers/aiml",
|
||||
"providers/bedrock_vector_store",
|
||||
]
|
||||
},
|
||||
"providers/litellm_proxy",
|
||||
"providers/abliteration",
|
||||
"providers/ai21",
|
||||
"providers/aiml",
|
||||
"providers/aleph_alpha",
|
||||
"providers/amazon_nova",
|
||||
"providers/anyscale",
|
||||
|
|
|
|||
|
|
@ -134,6 +134,7 @@ _custom_logger_compatible_callbacks_literal = Literal[
|
|||
"bitbucket",
|
||||
"gitlab",
|
||||
"cloudzero",
|
||||
"focus",
|
||||
"posthog",
|
||||
"levo",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -225,10 +225,13 @@ class BraintrustLogger(CustomLogger):
|
|||
"id": litellm_call_id,
|
||||
"input": prompt["messages"],
|
||||
"metadata": standard_logging_object,
|
||||
"tags": tags,
|
||||
"span_attributes": {"name": span_name, "type": "llm"},
|
||||
}
|
||||
|
||||
|
||||
# Braintrust cannot specify 'tags' for non-root spans
|
||||
if dynamic_metadata.get("root_span_id") is None:
|
||||
request_data["tags"] = tags
|
||||
|
||||
# Only add those that are not None (or falsy)
|
||||
for key, value in span_attributes.items():
|
||||
if value:
|
||||
|
|
@ -351,14 +354,37 @@ class BraintrustLogger(CustomLogger):
|
|||
# Allow metadata override for span name
|
||||
span_name = dynamic_metadata.get("span_name", "Chat Completion")
|
||||
|
||||
# Span parents is a special case
|
||||
span_parents = dynamic_metadata.get("span_parents")
|
||||
|
||||
# Convert comma-separated string to list if present
|
||||
if span_parents:
|
||||
span_parents = [s.strip() for s in span_parents.split(",") if s.strip()]
|
||||
|
||||
# Add optional span attributes only if present
|
||||
span_attributes = {
|
||||
"span_id": dynamic_metadata.get("span_id"),
|
||||
"root_span_id": dynamic_metadata.get("root_span_id"),
|
||||
"span_parents": span_parents,
|
||||
}
|
||||
|
||||
request_data = {
|
||||
"id": litellm_call_id,
|
||||
"input": prompt["messages"],
|
||||
"output": output,
|
||||
"metadata": standard_logging_object,
|
||||
"tags": tags,
|
||||
"span_attributes": {"name": span_name, "type": "llm"},
|
||||
}
|
||||
|
||||
# Braintrust cannot specify 'tags' for non-root spans
|
||||
if dynamic_metadata.get("root_span_id") is None:
|
||||
request_data["tags"] = tags
|
||||
|
||||
# Only add those that are not None (or falsy)
|
||||
for key, value in span_attributes.items():
|
||||
if value:
|
||||
request_data[key] = value
|
||||
|
||||
if choices is not None:
|
||||
request_data["output"] = [choice.dict() for choice in choices]
|
||||
else:
|
||||
|
|
@ -367,9 +393,6 @@ class BraintrustLogger(CustomLogger):
|
|||
if metrics is not None:
|
||||
request_data["metrics"] = metrics
|
||||
|
||||
if metrics is not None:
|
||||
request_data["metrics"] = metrics
|
||||
|
||||
try:
|
||||
await self.global_braintrust_http_handler.post(
|
||||
url=f"{self.api_base}/project_logs/{project_id}/insert",
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
"""Database connection and data extraction for LiteLLM."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Optional, List
|
||||
|
||||
import polars as pl
|
||||
|
||||
|
|
@ -46,19 +46,9 @@ class LiteLLMDatabase:
|
|||
"""Retrieve usage data from LiteLLM daily user spend table."""
|
||||
client = self._ensure_prisma_client()
|
||||
|
||||
# Build WHERE clause for time filtering
|
||||
where_conditions = []
|
||||
if start_time_utc:
|
||||
where_conditions.append(f"dus.updated_at >= '{start_time_utc.isoformat()}'")
|
||||
if end_time_utc:
|
||||
where_conditions.append(f"dus.updated_at <= '{end_time_utc.isoformat()}'")
|
||||
|
||||
where_clause = ""
|
||||
if where_conditions:
|
||||
where_clause = "WHERE " + " AND ".join(where_conditions)
|
||||
|
||||
# Query to get user spend data with team information
|
||||
query = f"""
|
||||
# Query to get user spend data with team information. Use parameter binding to
|
||||
# avoid SQL injection from user-supplied timestamps or limits.
|
||||
query = """
|
||||
SELECT
|
||||
dus.id,
|
||||
dus.date,
|
||||
|
|
@ -85,163 +75,27 @@ class LiteLLMDatabase:
|
|||
LEFT JOIN "LiteLLM_VerificationToken" vt ON dus.api_key = vt.token
|
||||
LEFT JOIN "LiteLLM_TeamTable" tt ON vt.team_id = tt.team_id
|
||||
LEFT JOIN "LiteLLM_UserTable" ut ON dus.user_id = ut.user_id
|
||||
{where_clause}
|
||||
WHERE ($1::timestamptz IS NULL OR dus.updated_at >= $1::timestamptz)
|
||||
AND ($2::timestamptz IS NULL OR dus.updated_at <= $2::timestamptz)
|
||||
ORDER BY dus.date DESC, dus.created_at DESC
|
||||
"""
|
||||
|
||||
if limit:
|
||||
query += f" LIMIT {limit}"
|
||||
params: List[Any] = [
|
||||
start_time_utc,
|
||||
end_time_utc,
|
||||
]
|
||||
|
||||
if limit is not None:
|
||||
try:
|
||||
params.append(int(limit))
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("limit must be an integer")
|
||||
query += " LIMIT $3"
|
||||
|
||||
try:
|
||||
db_response = await client.db.query_raw(query)
|
||||
db_response = await client.db.query_raw(query, *params)
|
||||
# Convert the response to polars DataFrame with full schema inference
|
||||
# This prevents schema mismatch errors when data types vary across rows
|
||||
return pl.DataFrame(db_response, infer_schema_length=None)
|
||||
except Exception as e:
|
||||
raise Exception(f"Error retrieving usage data: {str(e)}")
|
||||
|
||||
async def get_table_info(self) -> Dict[str, Any]:
|
||||
"""Get information about the daily user spend table."""
|
||||
client = self._ensure_prisma_client()
|
||||
|
||||
try:
|
||||
# Get row count from user spend table
|
||||
user_count = await self._get_table_row_count("LiteLLM_DailyUserSpend")
|
||||
|
||||
# Get column structure from user spend table
|
||||
query = """
|
||||
SELECT column_name, data_type, is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'LiteLLM_DailyUserSpend'
|
||||
ORDER BY ordinal_position;
|
||||
"""
|
||||
columns_response = await client.db.query_raw(query)
|
||||
|
||||
return {
|
||||
"columns": columns_response,
|
||||
"row_count": user_count,
|
||||
"table_name": "LiteLLM_DailyUserSpend",
|
||||
}
|
||||
except Exception as e:
|
||||
raise Exception(f"Error getting table info: {str(e)}")
|
||||
|
||||
async def _get_table_row_count(self, table_name: str) -> int:
|
||||
"""Get row count from specified table."""
|
||||
client = self._ensure_prisma_client()
|
||||
|
||||
try:
|
||||
query = f'SELECT COUNT(*) as count FROM "{table_name}"'
|
||||
response = await client.db.query_raw(query)
|
||||
|
||||
if response and len(response) > 0:
|
||||
return response[0].get("count", 0)
|
||||
return 0
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
async def discover_all_tables(self) -> Dict[str, Any]:
|
||||
"""Discover all tables in the LiteLLM database and their schemas."""
|
||||
client = self._ensure_prisma_client()
|
||||
|
||||
try:
|
||||
# Get all LiteLLM tables
|
||||
litellm_tables_query = """
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name LIKE 'LiteLLM_%'
|
||||
ORDER BY table_name;
|
||||
"""
|
||||
tables_response = await client.db.query_raw(litellm_tables_query)
|
||||
table_names = [row["table_name"] for row in tables_response]
|
||||
|
||||
# Get detailed schema for each table
|
||||
tables_info = {}
|
||||
for table_name in table_names:
|
||||
# Get column information
|
||||
columns_query = """
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable,
|
||||
column_default,
|
||||
character_maximum_length,
|
||||
numeric_precision,
|
||||
numeric_scale,
|
||||
ordinal_position
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = $1
|
||||
AND table_schema = 'public'
|
||||
ORDER BY ordinal_position;
|
||||
"""
|
||||
columns_response = await client.db.query_raw(columns_query, table_name)
|
||||
|
||||
# Get primary key information
|
||||
pk_query = """
|
||||
SELECT a.attname
|
||||
FROM pg_index i
|
||||
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
||||
WHERE i.indrelid = $1::regclass AND i.indisprimary;
|
||||
"""
|
||||
pk_response = await client.db.query_raw(pk_query, f'"{table_name}"')
|
||||
primary_keys = (
|
||||
[row["attname"] for row in pk_response] if pk_response else []
|
||||
)
|
||||
|
||||
# Get foreign key information
|
||||
fk_query = """
|
||||
SELECT
|
||||
tc.constraint_name,
|
||||
kcu.column_name,
|
||||
ccu.table_name AS foreign_table_name,
|
||||
ccu.column_name AS foreign_column_name
|
||||
FROM information_schema.table_constraints AS tc
|
||||
JOIN information_schema.key_column_usage AS kcu
|
||||
ON tc.constraint_name = kcu.constraint_name
|
||||
JOIN information_schema.constraint_column_usage AS ccu
|
||||
ON ccu.constraint_name = tc.constraint_name
|
||||
WHERE tc.constraint_type = 'FOREIGN KEY'
|
||||
AND tc.table_name = $1;
|
||||
"""
|
||||
fk_response = await client.db.query_raw(fk_query, table_name)
|
||||
foreign_keys = fk_response if fk_response else []
|
||||
|
||||
# Get indexes
|
||||
indexes_query = """
|
||||
SELECT
|
||||
i.relname AS index_name,
|
||||
array_agg(a.attname ORDER BY a.attnum) AS column_names,
|
||||
ix.indisunique AS is_unique
|
||||
FROM pg_class t
|
||||
JOIN pg_index ix ON t.oid = ix.indrelid
|
||||
JOIN pg_class i ON i.oid = ix.indexrelid
|
||||
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey)
|
||||
WHERE t.relname = $1
|
||||
AND t.relkind = 'r'
|
||||
GROUP BY i.relname, ix.indisunique
|
||||
ORDER BY i.relname;
|
||||
"""
|
||||
indexes_response = await client.db.query_raw(indexes_query, table_name)
|
||||
indexes = indexes_response if indexes_response else []
|
||||
|
||||
# Get row count
|
||||
try:
|
||||
row_count = await self._get_table_row_count(table_name)
|
||||
except Exception:
|
||||
row_count = 0
|
||||
|
||||
tables_info[table_name] = {
|
||||
"columns": columns_response,
|
||||
"primary_keys": primary_keys,
|
||||
"foreign_keys": foreign_keys,
|
||||
"indexes": indexes,
|
||||
"row_count": row_count,
|
||||
}
|
||||
|
||||
return {
|
||||
"tables": tables_info,
|
||||
"table_count": len(table_names),
|
||||
"table_names": table_names,
|
||||
}
|
||||
except Exception as e:
|
||||
raise Exception(f"Error discovering tables: {str(e)}")
|
||||
|
|
|
|||
0
litellm/integrations/focus/__init__.py
Normal file
0
litellm/integrations/focus/__init__.py
Normal file
113
litellm/integrations/focus/database.py
Normal file
113
litellm/integrations/focus/database.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
"""Database access helpers for Focus export."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import polars as pl
|
||||
|
||||
|
||||
class FocusLiteLLMDatabase:
|
||||
"""Retrieves LiteLLM usage data for Focus export workflows."""
|
||||
|
||||
def _ensure_prisma_client(self):
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise RuntimeError(
|
||||
"Database not connected. Connect a database to your proxy - "
|
||||
"https://docs.litellm.ai/docs/simple_proxy#managing-auth---virtual-keys"
|
||||
)
|
||||
return prisma_client
|
||||
|
||||
async def get_usage_data(
|
||||
self,
|
||||
*,
|
||||
limit: Optional[int] = None,
|
||||
start_time_utc: Optional[datetime] = None,
|
||||
end_time_utc: Optional[datetime] = None,
|
||||
) -> pl.DataFrame:
|
||||
"""Return usage data for the requested window."""
|
||||
client = self._ensure_prisma_client()
|
||||
|
||||
where_clauses: list[str] = []
|
||||
query_params: list[Any] = []
|
||||
placeholder_index = 1
|
||||
if start_time_utc:
|
||||
where_clauses.append(f"dus.updated_at >= ${placeholder_index}::timestamptz")
|
||||
query_params.append(start_time_utc)
|
||||
placeholder_index += 1
|
||||
if end_time_utc:
|
||||
where_clauses.append(f"dus.updated_at <= ${placeholder_index}::timestamptz")
|
||||
query_params.append(end_time_utc)
|
||||
placeholder_index += 1
|
||||
|
||||
where_clause = ""
|
||||
if where_clauses:
|
||||
where_clause = "WHERE " + " AND ".join(where_clauses)
|
||||
|
||||
limit_clause = ""
|
||||
if limit is not None:
|
||||
try:
|
||||
limit_value = int(limit)
|
||||
except (TypeError, ValueError) as exc: # pragma: no cover - defensive guard
|
||||
raise ValueError("limit must be an integer") from exc
|
||||
if limit_value < 0:
|
||||
raise ValueError("limit must be non-negative")
|
||||
limit_clause = f" LIMIT ${placeholder_index}"
|
||||
query_params.append(limit_value)
|
||||
|
||||
query = f"""
|
||||
SELECT
|
||||
dus.id,
|
||||
dus.date,
|
||||
dus.user_id,
|
||||
dus.api_key,
|
||||
dus.model,
|
||||
dus.model_group,
|
||||
dus.custom_llm_provider,
|
||||
dus.prompt_tokens,
|
||||
dus.completion_tokens,
|
||||
dus.spend,
|
||||
dus.api_requests,
|
||||
dus.successful_requests,
|
||||
dus.failed_requests,
|
||||
dus.cache_creation_input_tokens,
|
||||
dus.cache_read_input_tokens,
|
||||
dus.created_at,
|
||||
dus.updated_at,
|
||||
vt.team_id,
|
||||
vt.key_alias as api_key_alias,
|
||||
tt.team_alias,
|
||||
ut.user_email as user_email
|
||||
FROM "LiteLLM_DailyUserSpend" dus
|
||||
LEFT JOIN "LiteLLM_VerificationToken" vt ON dus.api_key = vt.token
|
||||
LEFT JOIN "LiteLLM_TeamTable" tt ON vt.team_id = tt.team_id
|
||||
LEFT JOIN "LiteLLM_UserTable" ut ON dus.user_id = ut.user_id
|
||||
{where_clause}
|
||||
ORDER BY dus.date DESC, dus.created_at DESC
|
||||
{limit_clause}
|
||||
"""
|
||||
|
||||
try:
|
||||
db_response = await client.db.query_raw(query, *query_params)
|
||||
return pl.DataFrame(db_response, infer_schema_length=None)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"Error retrieving usage data: {exc}") from exc
|
||||
|
||||
async def get_table_info(self) -> Dict[str, Any]:
|
||||
"""Return metadata about the spend table for diagnostics."""
|
||||
client = self._ensure_prisma_client()
|
||||
|
||||
info_query = """
|
||||
SELECT column_name, data_type, is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'LiteLLM_DailyUserSpend'
|
||||
ORDER BY ordinal_position;
|
||||
"""
|
||||
try:
|
||||
columns_response = await client.db.query_raw(info_query)
|
||||
return {"columns": columns_response, "table_name": "LiteLLM_DailyUserSpend"}
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"Error getting table info: {exc}") from exc
|
||||
12
litellm/integrations/focus/destinations/__init__.py
Normal file
12
litellm/integrations/focus/destinations/__init__.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
"""Destination implementations for Focus export."""
|
||||
|
||||
from .base import FocusDestination, FocusTimeWindow
|
||||
from .factory import FocusDestinationFactory
|
||||
from .s3_destination import FocusS3Destination
|
||||
|
||||
__all__ = [
|
||||
"FocusDestination",
|
||||
"FocusDestinationFactory",
|
||||
"FocusTimeWindow",
|
||||
"FocusS3Destination",
|
||||
]
|
||||
30
litellm/integrations/focus/destinations/base.py
Normal file
30
litellm/integrations/focus/destinations/base.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"""Abstract destination interfaces for Focus export."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FocusTimeWindow:
|
||||
"""Represents the span of data exported in a single batch."""
|
||||
|
||||
start_time: datetime
|
||||
end_time: datetime
|
||||
frequency: str
|
||||
|
||||
|
||||
class FocusDestination(Protocol):
|
||||
"""Protocol for anything that can receive Focus export files."""
|
||||
|
||||
async def deliver(
|
||||
self,
|
||||
*,
|
||||
content: bytes,
|
||||
time_window: FocusTimeWindow,
|
||||
filename: str,
|
||||
) -> None:
|
||||
"""Persist the serialized export for the provided time window."""
|
||||
...
|
||||
59
litellm/integrations/focus/destinations/factory.py
Normal file
59
litellm/integrations/focus/destinations/factory.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"""Factory helpers for Focus export destinations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .base import FocusDestination
|
||||
from .s3_destination import FocusS3Destination
|
||||
|
||||
|
||||
class FocusDestinationFactory:
|
||||
"""Builds destination instances based on provider/config settings."""
|
||||
|
||||
@staticmethod
|
||||
def create(
|
||||
*,
|
||||
provider: str,
|
||||
prefix: str,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
) -> FocusDestination:
|
||||
"""Return a destination implementation for the requested provider."""
|
||||
provider_lower = provider.lower()
|
||||
normalized_config = FocusDestinationFactory._resolve_config(
|
||||
provider=provider_lower, overrides=config or {}
|
||||
)
|
||||
if provider_lower == "s3":
|
||||
return FocusS3Destination(prefix=prefix, config=normalized_config)
|
||||
raise NotImplementedError(
|
||||
f"Provider '{provider}' not supported for Focus export"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_config(
|
||||
*,
|
||||
provider: str,
|
||||
overrides: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
if provider == "s3":
|
||||
resolved = {
|
||||
"bucket_name": overrides.get("bucket_name")
|
||||
or os.getenv("FOCUS_S3_BUCKET_NAME"),
|
||||
"region_name": overrides.get("region_name")
|
||||
or os.getenv("FOCUS_S3_REGION_NAME"),
|
||||
"endpoint_url": overrides.get("endpoint_url")
|
||||
or os.getenv("FOCUS_S3_ENDPOINT_URL"),
|
||||
"aws_access_key_id": overrides.get("aws_access_key_id")
|
||||
or os.getenv("FOCUS_S3_ACCESS_KEY"),
|
||||
"aws_secret_access_key": overrides.get("aws_secret_access_key")
|
||||
or os.getenv("FOCUS_S3_SECRET_KEY"),
|
||||
"aws_session_token": overrides.get("aws_session_token")
|
||||
or os.getenv("FOCUS_S3_SESSION_TOKEN"),
|
||||
}
|
||||
if not resolved.get("bucket_name"):
|
||||
raise ValueError("FOCUS_S3_BUCKET_NAME must be provided for S3 exports")
|
||||
return {k: v for k, v in resolved.items() if v is not None}
|
||||
raise NotImplementedError(
|
||||
f"Provider '{provider}' not supported for Focus export configuration"
|
||||
)
|
||||
74
litellm/integrations/focus/destinations/s3_destination.py
Normal file
74
litellm/integrations/focus/destinations/s3_destination.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"""S3 destination implementation for Focus export."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
import boto3
|
||||
|
||||
from .base import FocusDestination, FocusTimeWindow
|
||||
|
||||
|
||||
class FocusS3Destination(FocusDestination):
|
||||
"""Handles uploading serialized exports to S3 buckets."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
prefix: str,
|
||||
config: Optional[dict[str, Any]] = None,
|
||||
) -> None:
|
||||
config = config or {}
|
||||
bucket_name = config.get("bucket_name")
|
||||
if not bucket_name:
|
||||
raise ValueError("bucket_name must be provided for S3 destination")
|
||||
self.bucket_name = bucket_name
|
||||
self.prefix = prefix.rstrip("/")
|
||||
self.config = config
|
||||
|
||||
async def deliver(
|
||||
self,
|
||||
*,
|
||||
content: bytes,
|
||||
time_window: FocusTimeWindow,
|
||||
filename: str,
|
||||
) -> None:
|
||||
object_key = self._build_object_key(time_window=time_window, filename=filename)
|
||||
await asyncio.to_thread(self._upload, content, object_key)
|
||||
|
||||
def _build_object_key(self, *, time_window: FocusTimeWindow, filename: str) -> str:
|
||||
start_utc = time_window.start_time.astimezone(timezone.utc)
|
||||
date_component = f"date={start_utc.strftime('%Y-%m-%d')}"
|
||||
parts = [self.prefix, date_component]
|
||||
if time_window.frequency == "hourly":
|
||||
parts.append(f"hour={start_utc.strftime('%H')}")
|
||||
key_prefix = "/".join(filter(None, parts))
|
||||
return f"{key_prefix}/{filename}" if key_prefix else filename
|
||||
|
||||
def _upload(self, content: bytes, object_key: str) -> None:
|
||||
client_kwargs: dict[str, Any] = {}
|
||||
region_name = self.config.get("region_name")
|
||||
if region_name:
|
||||
client_kwargs["region_name"] = region_name
|
||||
endpoint_url = self.config.get("endpoint_url")
|
||||
if endpoint_url:
|
||||
client_kwargs["endpoint_url"] = endpoint_url
|
||||
|
||||
session_kwargs: dict[str, Any] = {}
|
||||
for key in (
|
||||
"aws_access_key_id",
|
||||
"aws_secret_access_key",
|
||||
"aws_session_token",
|
||||
):
|
||||
if self.config.get(key):
|
||||
session_kwargs[key] = self.config[key]
|
||||
|
||||
s3_client = boto3.client("s3", **client_kwargs, **session_kwargs)
|
||||
s3_client.put_object(
|
||||
Bucket=self.bucket_name,
|
||||
Key=object_key,
|
||||
Body=content,
|
||||
ContentType="application/octet-stream",
|
||||
)
|
||||
124
litellm/integrations/focus/export_engine.py
Normal file
124
litellm/integrations/focus/export_engine.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
"""Core export engine for Focus integrations (heavy dependencies)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import polars as pl
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
|
||||
from .database import FocusLiteLLMDatabase
|
||||
from .destinations import FocusDestinationFactory, FocusTimeWindow
|
||||
from .serializers import FocusParquetSerializer, FocusSerializer
|
||||
from .transformer import FocusTransformer
|
||||
|
||||
|
||||
class FocusExportEngine:
|
||||
"""Engine that fetches, normalizes, and uploads Focus exports."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
provider: str,
|
||||
export_format: str,
|
||||
prefix: str,
|
||||
destination_config: Optional[dict[str, Any]] = None,
|
||||
) -> None:
|
||||
self.provider = provider
|
||||
self.export_format = export_format
|
||||
self.prefix = prefix
|
||||
self._destination = FocusDestinationFactory.create(
|
||||
provider=self.provider,
|
||||
prefix=self.prefix,
|
||||
config=destination_config,
|
||||
)
|
||||
self._serializer = self._init_serializer()
|
||||
self._transformer = FocusTransformer()
|
||||
self._database = FocusLiteLLMDatabase()
|
||||
|
||||
def _init_serializer(self) -> FocusSerializer:
|
||||
if self.export_format != "parquet":
|
||||
raise NotImplementedError("Only parquet export supported currently")
|
||||
return FocusParquetSerializer()
|
||||
|
||||
async def dry_run_export_usage_data(self, limit: Optional[int]) -> Dict[str, Any]:
|
||||
data = await self._database.get_usage_data(limit=limit)
|
||||
normalized = self._transformer.transform(data)
|
||||
|
||||
usage_sample = data.head(min(50, len(data))).to_dicts()
|
||||
normalized_sample = normalized.head(min(50, len(normalized))).to_dicts()
|
||||
|
||||
summary = {
|
||||
"total_records": len(normalized),
|
||||
"total_spend": self._sum_column(normalized, "spend"),
|
||||
"total_tokens": self._sum_column(normalized, "total_tokens"),
|
||||
"unique_teams": self._count_unique(normalized, "team_id"),
|
||||
"unique_models": self._count_unique(normalized, "model"),
|
||||
}
|
||||
|
||||
return {
|
||||
"usage_data": usage_sample,
|
||||
"normalized_data": normalized_sample,
|
||||
"summary": summary,
|
||||
}
|
||||
|
||||
async def export_window(
|
||||
self,
|
||||
*,
|
||||
window: FocusTimeWindow,
|
||||
limit: Optional[int],
|
||||
) -> None:
|
||||
data = await self._database.get_usage_data(
|
||||
limit=limit,
|
||||
start_time_utc=window.start_time,
|
||||
end_time_utc=window.end_time,
|
||||
)
|
||||
if data.is_empty():
|
||||
verbose_logger.debug("Focus export: no usage data for window %s", window)
|
||||
return
|
||||
|
||||
normalized = self._transformer.transform(data)
|
||||
if normalized.is_empty():
|
||||
verbose_logger.debug(
|
||||
"Focus export: normalized data empty for window %s", window
|
||||
)
|
||||
return
|
||||
|
||||
await self._serialize_and_upload(normalized, window)
|
||||
|
||||
async def _serialize_and_upload(
|
||||
self, frame: pl.DataFrame, window: FocusTimeWindow
|
||||
) -> None:
|
||||
payload = self._serializer.serialize(frame)
|
||||
if not payload:
|
||||
verbose_logger.debug("Focus export: serializer returned empty payload")
|
||||
return
|
||||
await self._destination.deliver(
|
||||
content=payload,
|
||||
time_window=window,
|
||||
filename=self._build_filename(),
|
||||
)
|
||||
|
||||
def _build_filename(self) -> str:
|
||||
if not self._serializer.extension:
|
||||
raise ValueError("Serializer must declare a file extension")
|
||||
return f"usage.{self._serializer.extension}"
|
||||
|
||||
@staticmethod
|
||||
def _sum_column(frame: pl.DataFrame, column: str) -> float:
|
||||
if frame.is_empty() or column not in frame.columns:
|
||||
return 0.0
|
||||
value = frame.select(pl.col(column).sum().alias("sum")).row(0)[0]
|
||||
if value is None:
|
||||
return 0.0
|
||||
return float(value)
|
||||
|
||||
@staticmethod
|
||||
def _count_unique(frame: pl.DataFrame, column: str) -> int:
|
||||
if frame.is_empty() or column not in frame.columns:
|
||||
return 0
|
||||
value = frame.select(pl.col(column).n_unique().alias("unique")).row(0)[0]
|
||||
if value is None:
|
||||
return 0
|
||||
return int(value)
|
||||
211
litellm/integrations/focus/focus_logger.py
Normal file
211
litellm/integrations/focus/focus_logger.py
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
"""Focus export logger orchestrating DB pull/transform/upload."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, cast
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
|
||||
from .destinations import FocusTimeWindow
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from .export_engine import FocusExportEngine
|
||||
else:
|
||||
AsyncIOScheduler = Any
|
||||
|
||||
FOCUS_USAGE_DATA_JOB_NAME = "focus_export_usage_data"
|
||||
DEFAULT_DRY_RUN_LIMIT = 500
|
||||
|
||||
|
||||
class FocusLogger(CustomLogger):
|
||||
"""Coordinates Focus export jobs across transformer/serializer/destination layers."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
provider: Optional[str] = None,
|
||||
export_format: Optional[str] = None,
|
||||
frequency: Optional[str] = None,
|
||||
cron_offset_minute: Optional[int] = None,
|
||||
interval_seconds: Optional[int] = None,
|
||||
prefix: Optional[str] = None,
|
||||
destination_config: Optional[dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.provider = (provider or os.getenv("FOCUS_PROVIDER") or "s3").lower()
|
||||
self.export_format = (
|
||||
export_format or os.getenv("FOCUS_FORMAT") or "parquet"
|
||||
).lower()
|
||||
self.frequency = (frequency or os.getenv("FOCUS_FREQUENCY") or "hourly").lower()
|
||||
self.cron_offset_minute = (
|
||||
cron_offset_minute
|
||||
if cron_offset_minute is not None
|
||||
else int(os.getenv("FOCUS_CRON_OFFSET", "5"))
|
||||
)
|
||||
raw_interval = (
|
||||
interval_seconds
|
||||
if interval_seconds is not None
|
||||
else os.getenv("FOCUS_INTERVAL_SECONDS")
|
||||
)
|
||||
self.interval_seconds = int(raw_interval) if raw_interval is not None else None
|
||||
env_prefix = os.getenv("FOCUS_PREFIX")
|
||||
self.prefix: str = (
|
||||
prefix if prefix is not None else (env_prefix if env_prefix else "focus_exports")
|
||||
)
|
||||
|
||||
self._destination_config = destination_config
|
||||
self._engine: Optional["FocusExportEngine"] = None
|
||||
|
||||
def _ensure_engine(self) -> "FocusExportEngine":
|
||||
"""Instantiate the heavy export engine lazily."""
|
||||
if self._engine is None:
|
||||
from .export_engine import FocusExportEngine
|
||||
|
||||
self._engine = FocusExportEngine(
|
||||
provider=self.provider,
|
||||
export_format=self.export_format,
|
||||
prefix=self.prefix,
|
||||
destination_config=self._destination_config,
|
||||
)
|
||||
return self._engine
|
||||
|
||||
async def export_usage_data(
|
||||
self,
|
||||
*,
|
||||
limit: Optional[int] = None,
|
||||
start_time_utc: Optional[datetime] = None,
|
||||
end_time_utc: Optional[datetime] = None,
|
||||
) -> None:
|
||||
"""Public hook to trigger export immediately."""
|
||||
if bool(start_time_utc) ^ bool(end_time_utc):
|
||||
raise ValueError(
|
||||
"start_time_utc and end_time_utc must be provided together"
|
||||
)
|
||||
|
||||
if start_time_utc and end_time_utc:
|
||||
window = FocusTimeWindow(
|
||||
start_time=start_time_utc,
|
||||
end_time=end_time_utc,
|
||||
frequency=self.frequency,
|
||||
)
|
||||
else:
|
||||
window = self._compute_time_window(datetime.now(timezone.utc))
|
||||
await self._export_window(window=window, limit=limit)
|
||||
|
||||
async def dry_run_export_usage_data(
|
||||
self, limit: Optional[int] = DEFAULT_DRY_RUN_LIMIT
|
||||
) -> dict[str, Any]:
|
||||
"""Return transformed data without uploading."""
|
||||
engine = self._ensure_engine()
|
||||
return await engine.dry_run_export_usage_data(limit=limit)
|
||||
|
||||
async def initialize_focus_export_job(self) -> None:
|
||||
"""Entry point for scheduler jobs to run export cycle with locking."""
|
||||
from litellm.proxy.proxy_server import proxy_logging_obj
|
||||
|
||||
pod_lock_manager = None
|
||||
if proxy_logging_obj is not None:
|
||||
writer = getattr(proxy_logging_obj, "db_spend_update_writer", None)
|
||||
if writer is not None:
|
||||
pod_lock_manager = getattr(writer, "pod_lock_manager", None)
|
||||
|
||||
if pod_lock_manager and pod_lock_manager.redis_cache:
|
||||
acquired = await pod_lock_manager.acquire_lock(
|
||||
cronjob_id=FOCUS_USAGE_DATA_JOB_NAME
|
||||
)
|
||||
if not acquired:
|
||||
verbose_logger.debug("Focus export: unable to acquire pod lock")
|
||||
return
|
||||
try:
|
||||
await self._run_scheduled_export()
|
||||
finally:
|
||||
await pod_lock_manager.release_lock(
|
||||
cronjob_id=FOCUS_USAGE_DATA_JOB_NAME
|
||||
)
|
||||
else:
|
||||
await self._run_scheduled_export()
|
||||
|
||||
@staticmethod
|
||||
async def init_focus_export_background_job(
|
||||
scheduler: AsyncIOScheduler,
|
||||
) -> None:
|
||||
"""Register the export cron/interval job with the provided scheduler."""
|
||||
|
||||
focus_loggers: List[
|
||||
CustomLogger
|
||||
] = litellm.logging_callback_manager.get_custom_loggers_for_type(
|
||||
callback_type=FocusLogger
|
||||
)
|
||||
if not focus_loggers:
|
||||
verbose_logger.debug(
|
||||
"No Focus export logger registered; skipping scheduler"
|
||||
)
|
||||
return
|
||||
|
||||
focus_logger = cast(FocusLogger, focus_loggers[0])
|
||||
trigger_kwargs = focus_logger._build_scheduler_trigger()
|
||||
scheduler.add_job(
|
||||
focus_logger.initialize_focus_export_job,
|
||||
**trigger_kwargs,
|
||||
)
|
||||
|
||||
def _build_scheduler_trigger(self) -> Dict[str, Any]:
|
||||
"""Return scheduler configuration for the selected frequency."""
|
||||
if self.frequency == "interval":
|
||||
seconds = self.interval_seconds or 60
|
||||
return {"trigger": "interval", "seconds": seconds}
|
||||
|
||||
if self.frequency == "hourly":
|
||||
minute = max(0, min(59, self.cron_offset_minute))
|
||||
return {"trigger": "cron", "minute": minute, "second": 0}
|
||||
|
||||
if self.frequency == "daily":
|
||||
total_minutes = max(0, self.cron_offset_minute)
|
||||
hour = min(23, total_minutes // 60)
|
||||
minute = min(59, total_minutes % 60)
|
||||
return {"trigger": "cron", "hour": hour, "minute": minute, "second": 0}
|
||||
|
||||
raise ValueError(f"Unsupported frequency: {self.frequency}")
|
||||
|
||||
async def _run_scheduled_export(self) -> None:
|
||||
"""Execute the scheduled export for the configured window."""
|
||||
window = self._compute_time_window(datetime.now(timezone.utc))
|
||||
await self._export_window(window=window, limit=None)
|
||||
|
||||
async def _export_window(
|
||||
self,
|
||||
*,
|
||||
window: FocusTimeWindow,
|
||||
limit: Optional[int],
|
||||
) -> None:
|
||||
engine = self._ensure_engine()
|
||||
await engine.export_window(window=window, limit=limit)
|
||||
|
||||
def _compute_time_window(self, now: datetime) -> FocusTimeWindow:
|
||||
"""Derive the time window to export based on configured frequency."""
|
||||
now_utc = now.astimezone(timezone.utc)
|
||||
if self.frequency == "hourly":
|
||||
end_time = now_utc.replace(minute=0, second=0, microsecond=0)
|
||||
start_time = end_time - timedelta(hours=1)
|
||||
elif self.frequency == "daily":
|
||||
end_time = now_utc.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
start_time = end_time - timedelta(days=1)
|
||||
elif self.frequency == "interval":
|
||||
interval = timedelta(seconds=self.interval_seconds or 60)
|
||||
end_time = now_utc
|
||||
start_time = end_time - interval
|
||||
else:
|
||||
raise ValueError(f"Unsupported frequency: {self.frequency}")
|
||||
return FocusTimeWindow(
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
frequency=self.frequency,
|
||||
)
|
||||
|
||||
__all__ = ["FocusLogger"]
|
||||
50
litellm/integrations/focus/schema.py
Normal file
50
litellm/integrations/focus/schema.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"""Schema definitions for Focus export data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import polars as pl
|
||||
|
||||
# see: https://focus.finops.org/focus-specification/v1-2/
|
||||
FOCUS_NORMALIZED_SCHEMA = pl.Schema(
|
||||
[
|
||||
("BilledCost", pl.Decimal(18, 6)),
|
||||
("BillingAccountId", pl.String),
|
||||
("BillingAccountName", pl.String),
|
||||
("BillingCurrency", pl.String),
|
||||
("BillingPeriodStart", pl.Datetime(time_unit="us")),
|
||||
("BillingPeriodEnd", pl.Datetime(time_unit="us")),
|
||||
("ChargeCategory", pl.String),
|
||||
("ChargeClass", pl.String),
|
||||
("ChargeDescription", pl.String),
|
||||
("ChargeFrequency", pl.String),
|
||||
("ChargePeriodStart", pl.Datetime(time_unit="us")),
|
||||
("ChargePeriodEnd", pl.Datetime(time_unit="us")),
|
||||
("ConsumedQuantity", pl.Decimal(18, 6)),
|
||||
("ConsumedUnit", pl.String),
|
||||
("ContractedCost", pl.Decimal(18, 6)),
|
||||
("ContractedUnitPrice", pl.Decimal(18, 6)),
|
||||
("EffectiveCost", pl.Decimal(18, 6)),
|
||||
("InvoiceIssuerName", pl.String),
|
||||
("ListCost", pl.Decimal(18, 6)),
|
||||
("ListUnitPrice", pl.Decimal(18, 6)),
|
||||
("PricingCategory", pl.String),
|
||||
("PricingQuantity", pl.Decimal(18, 6)),
|
||||
("PricingUnit", pl.String),
|
||||
("ProviderName", pl.String),
|
||||
("PublisherName", pl.String),
|
||||
("RegionId", pl.String),
|
||||
("RegionName", pl.String),
|
||||
("ResourceId", pl.String),
|
||||
("ResourceName", pl.String),
|
||||
("ResourceType", pl.String),
|
||||
("ServiceCategory", pl.String),
|
||||
("ServiceSubcategory", pl.String),
|
||||
("ServiceName", pl.String),
|
||||
("SubAccountId", pl.String),
|
||||
("SubAccountName", pl.String),
|
||||
("SubAccountType", pl.String),
|
||||
("Tags", pl.Object),
|
||||
]
|
||||
)
|
||||
|
||||
__all__ = ["FOCUS_NORMALIZED_SCHEMA"]
|
||||
6
litellm/integrations/focus/serializers/__init__.py
Normal file
6
litellm/integrations/focus/serializers/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
"""Serializer package exports for Focus integration."""
|
||||
|
||||
from .base import FocusSerializer
|
||||
from .parquet import FocusParquetSerializer
|
||||
|
||||
__all__ = ["FocusSerializer", "FocusParquetSerializer"]
|
||||
18
litellm/integrations/focus/serializers/base.py
Normal file
18
litellm/integrations/focus/serializers/base.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
"""Serializer abstractions for Focus export."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
import polars as pl
|
||||
|
||||
|
||||
class FocusSerializer(ABC):
|
||||
"""Base serializer turning Focus frames into bytes."""
|
||||
|
||||
extension: str = ""
|
||||
|
||||
@abstractmethod
|
||||
def serialize(self, frame: pl.DataFrame) -> bytes:
|
||||
"""Convert the normalized Focus frame into the chosen format."""
|
||||
raise NotImplementedError
|
||||
22
litellm/integrations/focus/serializers/parquet.py
Normal file
22
litellm/integrations/focus/serializers/parquet.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
"""Parquet serializer for Focus export."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
import polars as pl
|
||||
|
||||
from .base import FocusSerializer
|
||||
|
||||
|
||||
class FocusParquetSerializer(FocusSerializer):
|
||||
"""Serialize normalized Focus frames to Parquet bytes."""
|
||||
|
||||
extension = "parquet"
|
||||
|
||||
def serialize(self, frame: pl.DataFrame) -> bytes:
|
||||
"""Encode the provided frame as a parquet payload."""
|
||||
target = frame if not frame.is_empty() else pl.DataFrame(schema=frame.schema)
|
||||
buffer = io.BytesIO()
|
||||
target.write_parquet(buffer, compression="snappy")
|
||||
return buffer.getvalue()
|
||||
90
litellm/integrations/focus/transformer.py
Normal file
90
litellm/integrations/focus/transformer.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"""Focus export data transformer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
import polars as pl
|
||||
|
||||
from .schema import FOCUS_NORMALIZED_SCHEMA
|
||||
|
||||
|
||||
class FocusTransformer:
|
||||
"""Transforms LiteLLM DB rows into Focus-compatible schema."""
|
||||
|
||||
schema = FOCUS_NORMALIZED_SCHEMA
|
||||
|
||||
def transform(self, frame: pl.DataFrame) -> pl.DataFrame:
|
||||
"""Return a normalized frame expected by downstream serializers."""
|
||||
if frame.is_empty():
|
||||
return pl.DataFrame(schema=self.schema)
|
||||
|
||||
# derive period start/end from usage date
|
||||
frame = frame.with_columns(
|
||||
pl.col("date")
|
||||
.cast(pl.Utf8)
|
||||
.str.strptime(pl.Datetime(time_unit="us"), format="%Y-%m-%d", strict=False)
|
||||
.alias("usage_date"),
|
||||
)
|
||||
frame = frame.with_columns(
|
||||
pl.col("usage_date").alias("ChargePeriodStart"),
|
||||
(pl.col("usage_date") + timedelta(days=1)).alias("ChargePeriodEnd"),
|
||||
)
|
||||
|
||||
def fmt(col):
|
||||
return col.dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
DEC = pl.Decimal(18, 6)
|
||||
|
||||
def dec(col):
|
||||
return col.cast(DEC)
|
||||
|
||||
none_str = pl.lit(None, dtype=pl.Utf8)
|
||||
none_dec = pl.lit(None, dtype=pl.Decimal(18, 6))
|
||||
|
||||
return frame.select(
|
||||
dec(pl.col("spend").fill_null(0.0)).alias("BilledCost"),
|
||||
pl.col("api_key").cast(pl.String).alias("BillingAccountId"),
|
||||
pl.col("api_key_alias").cast(pl.String).alias("BillingAccountName"),
|
||||
pl.lit("API Key").alias("BillingAccountType"),
|
||||
pl.lit("USD").alias("BillingCurrency"),
|
||||
fmt(pl.col("ChargePeriodEnd")).alias("BillingPeriodEnd"),
|
||||
fmt(pl.col("ChargePeriodStart")).alias("BillingPeriodStart"),
|
||||
pl.lit("Usage").alias("ChargeCategory"),
|
||||
none_str.alias("ChargeClass"),
|
||||
pl.col("model").cast(pl.String).alias("ChargeDescription"),
|
||||
pl.lit("Usage-Based").alias("ChargeFrequency"),
|
||||
fmt(pl.col("ChargePeriodEnd")).alias("ChargePeriodEnd"),
|
||||
fmt(pl.col("ChargePeriodStart")).alias("ChargePeriodStart"),
|
||||
dec(pl.lit(1.0)).alias("ConsumedQuantity"),
|
||||
pl.lit("Requests").alias("ConsumedUnit"),
|
||||
dec(pl.col("spend").fill_null(0.0)).alias("ContractedCost"),
|
||||
none_str.alias("ContractedUnitPrice"),
|
||||
dec(pl.col("spend").fill_null(0.0)).alias("EffectiveCost"),
|
||||
pl.col("custom_llm_provider").cast(pl.String).alias("InvoiceIssuerName"),
|
||||
none_str.alias("InvoiceId"),
|
||||
dec(pl.col("spend").fill_null(0.0)).alias("ListCost"),
|
||||
none_dec.alias("ListUnitPrice"),
|
||||
none_str.alias("AvailabilityZone"),
|
||||
pl.lit("USD").alias("PricingCurrency"),
|
||||
none_str.alias("PricingCategory"),
|
||||
dec(pl.lit(1.0)).alias("PricingQuantity"),
|
||||
none_dec.alias("PricingCurrencyContractedUnitPrice"),
|
||||
dec(pl.col("spend").fill_null(0.0)).alias("PricingCurrencyEffectiveCost"),
|
||||
none_dec.alias("PricingCurrencyListUnitPrice"),
|
||||
pl.lit("Requests").alias("PricingUnit"),
|
||||
pl.col("custom_llm_provider").cast(pl.String).alias("ProviderName"),
|
||||
pl.col("custom_llm_provider").cast(pl.String).alias("PublisherName"),
|
||||
none_str.alias("RegionId"),
|
||||
none_str.alias("RegionName"),
|
||||
pl.col("model").cast(pl.String).alias("ResourceId"),
|
||||
pl.col("model").cast(pl.String).alias("ResourceName"),
|
||||
pl.col("model").cast(pl.String).alias("ResourceType"),
|
||||
pl.lit("AI and Machine Learning").alias("ServiceCategory"),
|
||||
pl.lit("Generative AI").alias("ServiceSubcategory"),
|
||||
pl.col("model_group").cast(pl.String).alias("ServiceName"),
|
||||
pl.col("team_id").cast(pl.String).alias("SubAccountId"),
|
||||
pl.col("team_alias").cast(pl.String).alias("SubAccountName"),
|
||||
none_str.alias("SubAccountType"),
|
||||
none_str.alias("Tags"),
|
||||
)
|
||||
|
|
@ -1,28 +1,37 @@
|
|||
{
|
||||
"sample_callback": {
|
||||
"event_types": ["llm_api_success", "llm_api_failure"],
|
||||
"endpoint": "{{environment_variables.SAMPLE_CALLBACK_URL}}",
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer {{environment_variables.SAMPLE_CALLBACK_API_KEY}}"
|
||||
},
|
||||
"environment_variables": ["SAMPLE_CALLBACK_URL", "SAMPLE_CALLBACK_API_KEY"]
|
||||
"sample_callback": {
|
||||
"event_types": ["llm_api_success", "llm_api_failure"],
|
||||
"endpoint": "{{environment_variables.SAMPLE_CALLBACK_URL}}",
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer {{environment_variables.SAMPLE_CALLBACK_API_KEY}}"
|
||||
},
|
||||
"rubrik": {
|
||||
"event_types": ["llm_api_success"],
|
||||
"endpoint": "{{environment_variables.RUBRIK_WEBHOOK_URL}}",
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer {{environment_variables.RUBRIK_API_KEY}}"
|
||||
},
|
||||
"environment_variables": ["RUBRIK_API_KEY", "RUBRIK_WEBHOOK_URL"]
|
||||
"environment_variables": ["SAMPLE_CALLBACK_URL", "SAMPLE_CALLBACK_API_KEY"]
|
||||
},
|
||||
"rubrik": {
|
||||
"event_types": ["llm_api_success"],
|
||||
"endpoint": "{{environment_variables.RUBRIK_WEBHOOK_URL}}",
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer {{environment_variables.RUBRIK_API_KEY}}"
|
||||
},
|
||||
"sumologic": {
|
||||
"endpoint": "{{environment_variables.SUMOLOGIC_WEBHOOK_URL}}",
|
||||
"headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"environment_variables": ["SUMOLOGIC_WEBHOOK_URL"],
|
||||
"log_format": "ndjson"
|
||||
}
|
||||
}
|
||||
"environment_variables": ["RUBRIK_API_KEY", "RUBRIK_WEBHOOK_URL"]
|
||||
},
|
||||
"sumologic": {
|
||||
"endpoint": "{{environment_variables.SUMOLOGIC_WEBHOOK_URL}}",
|
||||
"headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"environment_variables": ["SUMOLOGIC_WEBHOOK_URL"],
|
||||
"log_format": "ndjson"
|
||||
},
|
||||
"qualifire_eval": {
|
||||
"event_types": ["llm_api_success"],
|
||||
"endpoint": "{{environment_variables.QUALIFIRE_WEBHOOK_URL}}",
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"X-Qualifire-API-Key": "{{environment_variables.QUALIFIRE_API_KEY}}"
|
||||
},
|
||||
"environment_variables": ["QUALIFIRE_API_KEY", "QUALIFIRE_WEBHOOK_URL"]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ def _get_cached_end_user_id_for_cost_tracking():
|
|||
global _get_end_user_id_for_cost_tracking
|
||||
if _get_end_user_id_for_cost_tracking is None:
|
||||
from litellm.utils import get_end_user_id_for_cost_tracking
|
||||
|
||||
_get_end_user_id_for_cost_tracking = get_end_user_id_for_cost_tracking
|
||||
return _get_end_user_id_for_cost_tracking
|
||||
|
||||
|
|
@ -262,6 +263,36 @@ class PrometheusLogger(CustomLogger):
|
|||
),
|
||||
buckets=LATENCY_BUCKETS,
|
||||
)
|
||||
|
||||
# Request queue time metric
|
||||
self.litellm_request_queue_time_metric = self._histogram_factory(
|
||||
"litellm_request_queue_time_seconds",
|
||||
"Time spent in request queue before processing starts (seconds)",
|
||||
labelnames=self.get_labels_for_metric(
|
||||
"litellm_request_queue_time_seconds"
|
||||
),
|
||||
buckets=LATENCY_BUCKETS,
|
||||
)
|
||||
|
||||
# Guardrail metrics
|
||||
self.litellm_guardrail_latency_metric = self._histogram_factory(
|
||||
"litellm_guardrail_latency_seconds",
|
||||
"Latency (seconds) for guardrail execution",
|
||||
labelnames=["guardrail_name", "status", "error_type", "hook_type"],
|
||||
buckets=LATENCY_BUCKETS,
|
||||
)
|
||||
|
||||
self.litellm_guardrail_errors_total = self._counter_factory(
|
||||
"litellm_guardrail_errors_total",
|
||||
"Total number of errors encountered during guardrail execution",
|
||||
labelnames=["guardrail_name", "error_type", "hook_type"],
|
||||
)
|
||||
|
||||
self.litellm_guardrail_requests_total = self._counter_factory(
|
||||
"litellm_guardrail_requests_total",
|
||||
"Total number of guardrail invocations",
|
||||
labelnames=["guardrail_name", "status", "hook_type"],
|
||||
)
|
||||
# llm api provider budget metrics
|
||||
self.litellm_provider_remaining_budget_metric = self._gauge_factory(
|
||||
"litellm_provider_remaining_budget_metric",
|
||||
|
|
@ -354,6 +385,25 @@ class PrometheusLogger(CustomLogger):
|
|||
labelnames=self.get_labels_for_metric("litellm_requests_metric"),
|
||||
)
|
||||
|
||||
# Cache metrics
|
||||
self.litellm_cache_hits_metric = self._counter_factory(
|
||||
name="litellm_cache_hits_metric",
|
||||
documentation="Total number of LiteLLM cache hits",
|
||||
labelnames=self.get_labels_for_metric("litellm_cache_hits_metric"),
|
||||
)
|
||||
|
||||
self.litellm_cache_misses_metric = self._counter_factory(
|
||||
name="litellm_cache_misses_metric",
|
||||
documentation="Total number of LiteLLM cache misses",
|
||||
labelnames=self.get_labels_for_metric("litellm_cache_misses_metric"),
|
||||
)
|
||||
|
||||
self.litellm_cached_tokens_metric = self._counter_factory(
|
||||
name="litellm_cached_tokens_metric",
|
||||
documentation="Total tokens served from LiteLLM cache",
|
||||
labelnames=self.get_labels_for_metric("litellm_cached_tokens_metric"),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print_verbose(f"Got exception on init prometheus client {str(e)}")
|
||||
raise e
|
||||
|
|
@ -825,7 +875,7 @@ class PrometheusLogger(CustomLogger):
|
|||
litellm_params = kwargs.get("litellm_params", {}) or {}
|
||||
_metadata = litellm_params.get("metadata", {})
|
||||
get_end_user_id_for_cost_tracking = _get_cached_end_user_id_for_cost_tracking()
|
||||
|
||||
|
||||
end_user_id = get_end_user_id_for_cost_tracking(
|
||||
litellm_params, service_type="prometheus"
|
||||
)
|
||||
|
|
@ -845,7 +895,7 @@ class PrometheusLogger(CustomLogger):
|
|||
user_api_key_auth_metadata: Optional[dict] = standard_logging_payload[
|
||||
"metadata"
|
||||
].get("user_api_key_auth_metadata")
|
||||
|
||||
|
||||
# Include top-level metadata fields (excluding nested dictionaries)
|
||||
# This allows accessing fields like requester_ip_address from top-level metadata
|
||||
top_level_metadata = standard_logging_payload.get("metadata", {})
|
||||
|
|
@ -856,7 +906,7 @@ class PrometheusLogger(CustomLogger):
|
|||
for k, v in top_level_metadata.items()
|
||||
if not isinstance(v, dict) # Exclude nested dicts to avoid conflicts
|
||||
}
|
||||
|
||||
|
||||
combined_metadata: Dict[str, Any] = {
|
||||
**top_level_fields, # Include top-level fields first
|
||||
**(_requester_metadata if _requester_metadata else {}),
|
||||
|
|
@ -976,6 +1026,12 @@ class PrometheusLogger(CustomLogger):
|
|||
kwargs, start_time, end_time, enum_values, output_tokens
|
||||
)
|
||||
|
||||
# cache metrics
|
||||
self._increment_cache_metrics(
|
||||
standard_logging_payload=standard_logging_payload, # type: ignore
|
||||
enum_values=enum_values,
|
||||
)
|
||||
|
||||
if (
|
||||
standard_logging_payload["stream"] is True
|
||||
): # log successful streaming requests from logging event hook.
|
||||
|
|
@ -1045,6 +1101,54 @@ class PrometheusLogger(CustomLogger):
|
|||
standard_logging_payload["completion_tokens"]
|
||||
)
|
||||
|
||||
def _increment_cache_metrics(
|
||||
self,
|
||||
standard_logging_payload: StandardLoggingPayload,
|
||||
enum_values: UserAPIKeyLabelValues,
|
||||
):
|
||||
"""
|
||||
Increment cache-related Prometheus metrics based on cache hit/miss status.
|
||||
|
||||
Args:
|
||||
standard_logging_payload: Contains cache_hit field (True/False/None)
|
||||
enum_values: Label values for Prometheus metrics
|
||||
"""
|
||||
cache_hit = standard_logging_payload.get("cache_hit")
|
||||
|
||||
# Only track if cache_hit has a definite value (True or False)
|
||||
if cache_hit is None:
|
||||
return
|
||||
|
||||
if cache_hit is True:
|
||||
# Increment cache hits counter
|
||||
_labels = prometheus_label_factory(
|
||||
supported_enum_labels=self.get_labels_for_metric(
|
||||
metric_name="litellm_cache_hits_metric"
|
||||
),
|
||||
enum_values=enum_values,
|
||||
)
|
||||
self.litellm_cache_hits_metric.labels(**_labels).inc()
|
||||
|
||||
# Increment cached tokens counter
|
||||
total_tokens = standard_logging_payload.get("total_tokens", 0)
|
||||
if total_tokens > 0:
|
||||
_labels = prometheus_label_factory(
|
||||
supported_enum_labels=self.get_labels_for_metric(
|
||||
metric_name="litellm_cached_tokens_metric"
|
||||
),
|
||||
enum_values=enum_values,
|
||||
)
|
||||
self.litellm_cached_tokens_metric.labels(**_labels).inc(total_tokens)
|
||||
else:
|
||||
# cache_hit is False - increment cache misses counter
|
||||
_labels = prometheus_label_factory(
|
||||
supported_enum_labels=self.get_labels_for_metric(
|
||||
metric_name="litellm_cache_misses_metric"
|
||||
),
|
||||
enum_values=enum_values,
|
||||
)
|
||||
self.litellm_cache_misses_metric.labels(**_labels).inc()
|
||||
|
||||
async def _increment_remaining_budget_metrics(
|
||||
self,
|
||||
user_api_team: Optional[str],
|
||||
|
|
@ -1229,6 +1333,22 @@ class PrometheusLogger(CustomLogger):
|
|||
total_time_seconds
|
||||
)
|
||||
|
||||
# request queue time (time from arrival to processing start)
|
||||
_litellm_params = kwargs.get("litellm_params", {}) or {}
|
||||
queue_time_seconds = _litellm_params.get("metadata", {}).get(
|
||||
"queue_time_seconds"
|
||||
)
|
||||
if queue_time_seconds is not None and queue_time_seconds >= 0:
|
||||
_labels = prometheus_label_factory(
|
||||
supported_enum_labels=self.get_labels_for_metric(
|
||||
metric_name="litellm_request_queue_time_seconds"
|
||||
),
|
||||
enum_values=enum_values,
|
||||
)
|
||||
self.litellm_request_queue_time_metric.labels(**_labels).observe(
|
||||
queue_time_seconds
|
||||
)
|
||||
|
||||
async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time):
|
||||
from litellm.types.utils import StandardLoggingPayload
|
||||
|
||||
|
|
@ -1249,7 +1369,7 @@ class PrometheusLogger(CustomLogger):
|
|||
|
||||
litellm_params = kwargs.get("litellm_params", {}) or {}
|
||||
get_end_user_id_for_cost_tracking = _get_cached_end_user_id_for_cost_tracking()
|
||||
|
||||
|
||||
end_user_id = get_end_user_id_for_cost_tracking(
|
||||
litellm_params, service_type="prometheus"
|
||||
)
|
||||
|
|
@ -1603,7 +1723,6 @@ class PrometheusLogger(CustomLogger):
|
|||
api_provider=llm_provider or "",
|
||||
)
|
||||
if exception is not None:
|
||||
|
||||
_labels = prometheus_label_factory(
|
||||
supported_enum_labels=self.get_labels_for_metric(
|
||||
metric_name="litellm_deployment_failure_responses"
|
||||
|
|
@ -1636,12 +1755,11 @@ class PrometheusLogger(CustomLogger):
|
|||
enum_values: UserAPIKeyLabelValues,
|
||||
output_tokens: float = 1.0,
|
||||
):
|
||||
|
||||
try:
|
||||
verbose_logger.debug("setting remaining tokens requests metric")
|
||||
standard_logging_payload: Optional[StandardLoggingPayload] = (
|
||||
request_kwargs.get("standard_logging_object")
|
||||
)
|
||||
standard_logging_payload: Optional[
|
||||
StandardLoggingPayload
|
||||
] = request_kwargs.get("standard_logging_object")
|
||||
|
||||
if standard_logging_payload is None:
|
||||
return
|
||||
|
|
@ -1784,6 +1902,50 @@ class PrometheusLogger(CustomLogger):
|
|||
)
|
||||
return
|
||||
|
||||
def _record_guardrail_metrics(
|
||||
self,
|
||||
guardrail_name: str,
|
||||
latency_seconds: float,
|
||||
status: str,
|
||||
error_type: Optional[str],
|
||||
hook_type: str,
|
||||
):
|
||||
"""
|
||||
Record guardrail metrics for prometheus.
|
||||
|
||||
Args:
|
||||
guardrail_name: Name of the guardrail
|
||||
latency_seconds: Execution latency in seconds
|
||||
status: "success" or "error"
|
||||
error_type: Type of error if any, None otherwise
|
||||
hook_type: "pre_call", "during_call", or "post_call"
|
||||
"""
|
||||
try:
|
||||
# Record latency
|
||||
self.litellm_guardrail_latency_metric.labels(
|
||||
guardrail_name=guardrail_name,
|
||||
status=status,
|
||||
error_type=error_type or "none",
|
||||
hook_type=hook_type,
|
||||
).observe(latency_seconds)
|
||||
|
||||
# Record request count
|
||||
self.litellm_guardrail_requests_total.labels(
|
||||
guardrail_name=guardrail_name,
|
||||
status=status,
|
||||
hook_type=hook_type,
|
||||
).inc()
|
||||
|
||||
# Record error count if there was an error
|
||||
if status == "error" and error_type:
|
||||
self.litellm_guardrail_errors_total.labels(
|
||||
guardrail_name=guardrail_name,
|
||||
error_type=error_type,
|
||||
hook_type=hook_type,
|
||||
).inc()
|
||||
except Exception as e:
|
||||
verbose_logger.debug(f"Error recording guardrail metrics: {str(e)}")
|
||||
|
||||
@staticmethod
|
||||
def _get_exception_class_name(exception: Exception) -> str:
|
||||
exception_class_name = ""
|
||||
|
|
@ -2572,10 +2734,10 @@ class PrometheusLogger(CustomLogger):
|
|||
from litellm.constants import PROMETHEUS_BUDGET_METRICS_REFRESH_INTERVAL_MINUTES
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
|
||||
prometheus_loggers: List[CustomLogger] = (
|
||||
litellm.logging_callback_manager.get_custom_loggers_for_type(
|
||||
callback_type=PrometheusLogger
|
||||
)
|
||||
prometheus_loggers: List[
|
||||
CustomLogger
|
||||
] = litellm.logging_callback_manager.get_custom_loggers_for_type(
|
||||
callback_type=PrometheusLogger
|
||||
)
|
||||
# we need to get the initialized prometheus logger instance(s) and call logger.initialize_remaining_budget_metrics() on them
|
||||
verbose_logger.debug("found %s prometheus loggers", len(prometheus_loggers))
|
||||
|
|
@ -2647,7 +2809,7 @@ def prometheus_label_factory(
|
|||
|
||||
if UserAPIKeyLabelNames.END_USER.value in filtered_labels:
|
||||
get_end_user_id_for_cost_tracking = _get_cached_end_user_id_for_cost_tracking()
|
||||
|
||||
|
||||
filtered_labels["end_user"] = get_end_user_id_for_cost_tracking(
|
||||
litellm_params={"user_api_key_end_user_id": enum_values.end_user},
|
||||
service_type="prometheus",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from litellm.integrations.azure_storage.azure_storage import AzureBlobStorageLog
|
|||
from litellm.integrations.bitbucket import BitBucketPromptManager
|
||||
from litellm.integrations.braintrust_logging import BraintrustLogger
|
||||
from litellm.integrations.cloudzero.cloudzero import CloudZeroLogger
|
||||
from litellm.integrations.focus.focus_logger import FocusLogger
|
||||
from litellm.integrations.datadog.datadog import DataDogLogger
|
||||
from litellm.integrations.datadog.datadog_llm_obs import DataDogLLMObsLogger
|
||||
from litellm.integrations.deepeval import DeepEvalLogger
|
||||
|
|
@ -93,6 +94,7 @@ class CustomLoggerRegistry:
|
|||
"bitbucket": BitBucketPromptManager,
|
||||
"gitlab": GitLabPromptManager,
|
||||
"cloudzero": CloudZeroLogger,
|
||||
"focus": FocusLogger,
|
||||
"posthog": PostHogLogger,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3756,6 +3756,15 @@ def _init_custom_logger_compatible_class( # noqa: PLR0915
|
|||
cloudzero_logger = CloudZeroLogger()
|
||||
_in_memory_loggers.append(cloudzero_logger)
|
||||
return cloudzero_logger # type: ignore
|
||||
elif logging_integration == "focus":
|
||||
from litellm.integrations.focus.focus_logger import FocusLogger
|
||||
|
||||
for callback in _in_memory_loggers:
|
||||
if isinstance(callback, FocusLogger):
|
||||
return callback # type: ignore
|
||||
focus_logger = FocusLogger()
|
||||
_in_memory_loggers.append(focus_logger)
|
||||
return focus_logger # type: ignore
|
||||
elif logging_integration == "deepeval":
|
||||
for callback in _in_memory_loggers:
|
||||
if isinstance(callback, DeepEvalLogger):
|
||||
|
|
@ -4076,6 +4085,12 @@ def get_custom_logger_compatible_class( # noqa: PLR0915
|
|||
for callback in _in_memory_loggers:
|
||||
if isinstance(callback, CloudZeroLogger):
|
||||
return callback
|
||||
elif logging_integration == "focus":
|
||||
from litellm.integrations.focus.focus_logger import FocusLogger
|
||||
|
||||
for callback in _in_memory_loggers:
|
||||
if isinstance(callback, FocusLogger):
|
||||
return callback
|
||||
elif logging_integration == "deepeval":
|
||||
for callback in _in_memory_loggers:
|
||||
if isinstance(callback, DeepEvalLogger):
|
||||
|
|
@ -4824,9 +4839,9 @@ class StandardLoggingPayloadSetup:
|
|||
metadata = litellm_params.get("metadata") or {}
|
||||
litellm_metadata = litellm_params.get("litellm_metadata") or {}
|
||||
if metadata.get("tags", []):
|
||||
request_tags = metadata.get("tags", [])
|
||||
request_tags = metadata.get("tags", []).copy()
|
||||
elif litellm_metadata.get("tags", []):
|
||||
request_tags = litellm_metadata.get("tags", [])
|
||||
request_tags = litellm_metadata.get("tags", []).copy()
|
||||
else:
|
||||
request_tags = []
|
||||
user_agent_tags = StandardLoggingPayloadSetup._get_user_agent_tags(
|
||||
|
|
|
|||
|
|
@ -1645,9 +1645,12 @@ def convert_to_anthropic_tool_result(
|
|||
)
|
||||
elif content["type"] == "image_url":
|
||||
format = content["image_url"].get("format") if isinstance(content["image_url"], dict) else None
|
||||
anthropic_content_list.append(
|
||||
create_anthropic_image_param(content["image_url"], format=format)
|
||||
_anthropic_image_param = create_anthropic_image_param(content["image_url"], format=format)
|
||||
_anthropic_image_param = add_cache_control_to_content(
|
||||
anthropic_content_element=_anthropic_image_param,
|
||||
original_content_element=content,
|
||||
)
|
||||
anthropic_content_list.append(_anthropic_image_param)
|
||||
|
||||
anthropic_content = anthropic_content_list
|
||||
anthropic_tool_result: Optional[AnthropicMessagesToolResultParam] = None
|
||||
|
|
|
|||
|
|
@ -25,7 +25,24 @@ class AzureOpenAIGPT5Config(AzureOpenAIConfig, OpenAIGPT5Config):
|
|||
return "gpt-5" in model or "gpt5_series" in model
|
||||
|
||||
def get_supported_openai_params(self, model: str) -> List[str]:
|
||||
return OpenAIGPT5Config.get_supported_openai_params(self, model=model)
|
||||
"""Get supported parameters for Azure OpenAI GPT-5 models.
|
||||
|
||||
Azure OpenAI GPT-5.2 models support logprobs, unlike OpenAI's GPT-5.
|
||||
This overrides the parent class to add logprobs support back for gpt-5.2.
|
||||
|
||||
Reference:
|
||||
- Tested with Azure OpenAI GPT-5.2 (api-version: 2025-01-01-preview)
|
||||
- Azure returns logprobs successfully despite Microsoft's general
|
||||
documentation stating reasoning models don't support it.
|
||||
"""
|
||||
params = OpenAIGPT5Config.get_supported_openai_params(self, model=model)
|
||||
|
||||
# Only gpt-5.2 has been verified to support logprobs on Azure
|
||||
if self.is_model_gpt_5_2_model(model):
|
||||
azure_supported_params = ["logprobs", "top_logprobs"]
|
||||
params.extend(azure_supported_params)
|
||||
|
||||
return params
|
||||
|
||||
def map_openai_params(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -314,6 +314,12 @@ class BaseAWSLLM:
|
|||
if model.startswith("invoke/"):
|
||||
model = model.replace("invoke/", "", 1)
|
||||
|
||||
# Special case: Check for "nova" in model name first (before "amazon")
|
||||
# This handles amazon.nova-* models which would otherwise match "amazon" (Titan)
|
||||
if "nova" in model.lower():
|
||||
if "nova" in get_args(BEDROCK_INVOKE_PROVIDERS_LITERAL):
|
||||
return cast(BEDROCK_INVOKE_PROVIDERS_LITERAL, "nova")
|
||||
|
||||
_split_model = model.split(".")[0]
|
||||
if _split_model in get_args(BEDROCK_INVOKE_PROVIDERS_LITERAL):
|
||||
return cast(BEDROCK_INVOKE_PROVIDERS_LITERAL, _split_model)
|
||||
|
|
@ -323,13 +329,9 @@ class BaseAWSLLM:
|
|||
if provider is not None:
|
||||
return provider
|
||||
|
||||
# check if provider == "nova"
|
||||
if "nova" in model:
|
||||
return "nova"
|
||||
else:
|
||||
for provider in get_args(BEDROCK_INVOKE_PROVIDERS_LITERAL):
|
||||
if provider in model:
|
||||
return provider
|
||||
for provider in get_args(BEDROCK_INVOKE_PROVIDERS_LITERAL):
|
||||
if provider in model:
|
||||
return provider
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -364,7 +366,7 @@ class BaseAWSLLM:
|
|||
elif provider == "qwen3" and "qwen3/" in model_id:
|
||||
model_id = BaseAWSLLM._get_model_id_from_model_with_spec(
|
||||
model_id, spec="qwen3"
|
||||
)
|
||||
)
|
||||
elif provider == "stability" and "stability/" in model_id:
|
||||
model_id = BaseAWSLLM._get_model_id_from_model_with_spec(
|
||||
model_id, spec="stability"
|
||||
|
|
@ -416,7 +418,7 @@ class BaseAWSLLM:
|
|||
if "nova" in model.lower():
|
||||
if "nova" in get_args(BEDROCK_EMBEDDING_PROVIDERS_LITERAL):
|
||||
return cast(BEDROCK_EMBEDDING_PROVIDERS_LITERAL, "nova")
|
||||
|
||||
|
||||
# Handle regional models like us.twelvelabs.marengo-embed-2-7-v1:0
|
||||
if "." in model:
|
||||
parts = model.split(".")
|
||||
|
|
@ -962,7 +964,9 @@ class BaseAWSLLM:
|
|||
return endpoint_url, proxy_endpoint_url
|
||||
|
||||
def _select_default_endpoint_url(
|
||||
self, endpoint_type: Optional[Literal["runtime", "agent", "agentcore"]], aws_region_name: str
|
||||
self,
|
||||
endpoint_type: Optional[Literal["runtime", "agent", "agentcore"]],
|
||||
aws_region_name: str,
|
||||
) -> str:
|
||||
"""
|
||||
Select the default endpoint url based on the endpoint type
|
||||
|
|
|
|||
|
|
@ -524,6 +524,12 @@ class AmazonInvokeConfig(BaseConfig, BaseAWSLLM):
|
|||
if model.startswith("invoke/"):
|
||||
model = model.replace("invoke/", "", 1)
|
||||
|
||||
# Special case: Check for "nova" in model name first (before "amazon")
|
||||
# This handles amazon.nova-* models which would otherwise match "amazon" (Titan)
|
||||
if "nova" in model.lower():
|
||||
if "nova" in get_args(litellm.BEDROCK_INVOKE_PROVIDERS_LITERAL):
|
||||
return cast(litellm.BEDROCK_INVOKE_PROVIDERS_LITERAL, "nova")
|
||||
|
||||
_split_model = model.split(".")[0]
|
||||
if _split_model in get_args(litellm.BEDROCK_INVOKE_PROVIDERS_LITERAL):
|
||||
return cast(litellm.BEDROCK_INVOKE_PROVIDERS_LITERAL, _split_model)
|
||||
|
|
@ -533,10 +539,6 @@ class AmazonInvokeConfig(BaseConfig, BaseAWSLLM):
|
|||
if provider is not None:
|
||||
return provider
|
||||
|
||||
# check if provider == "nova"
|
||||
if "nova" in model:
|
||||
return "nova"
|
||||
|
||||
for provider in get_args(litellm.BEDROCK_INVOKE_PROVIDERS_LITERAL):
|
||||
if provider in model:
|
||||
return provider
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import litellm
|
|||
from litellm.llms.base_llm.anthropic_messages.transformation import (
|
||||
BaseAnthropicMessagesConfig,
|
||||
)
|
||||
from litellm.llms.base_llm.base_utils import BaseLLMModelInfo
|
||||
from litellm.llms.base_llm.base_utils import BaseLLMModelInfo, BaseTokenCounter
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
from litellm.secret_managers.main import get_secret
|
||||
|
||||
|
|
@ -359,6 +359,70 @@ def get_bedrock_tool_name(response_tool_name: str) -> str:
|
|||
return response_tool_name
|
||||
|
||||
|
||||
# Cache the global regions list at module level
|
||||
_BEDROCK_GLOBAL_REGIONS: Optional[List[str]] = None
|
||||
|
||||
|
||||
def _get_all_bedrock_regions() -> List[str]:
|
||||
"""Get all Bedrock regions, cached at module level."""
|
||||
global _BEDROCK_GLOBAL_REGIONS
|
||||
if _BEDROCK_GLOBAL_REGIONS is None:
|
||||
_BEDROCK_GLOBAL_REGIONS = AmazonBedrockGlobalConfig().get_all_regions()
|
||||
return _BEDROCK_GLOBAL_REGIONS
|
||||
|
||||
|
||||
def get_bedrock_cross_region_inference_regions() -> List[str]:
|
||||
"""Abbreviations of regions AWS Bedrock supports for cross region inference."""
|
||||
return ["global", "us", "eu", "apac", "jp", "au", "us-gov"]
|
||||
|
||||
|
||||
def extract_model_name_from_bedrock_arn(model: str) -> str:
|
||||
"""
|
||||
Extract the model name from an AWS Bedrock ARN.
|
||||
Returns the string after the last '/' if 'arn' is in the input string.
|
||||
"""
|
||||
if "arn" in model.lower():
|
||||
return model.split("/")[-1]
|
||||
return model
|
||||
|
||||
|
||||
def strip_bedrock_routing_prefix(model: str) -> str:
|
||||
"""Strip LiteLLM routing prefixes from model name."""
|
||||
for prefix in ["bedrock/", "converse/", "invoke/", "openai/"]:
|
||||
if model.startswith(prefix):
|
||||
model = model.split("/", 1)[1]
|
||||
return model
|
||||
|
||||
|
||||
def get_bedrock_base_model(model: str) -> str:
|
||||
"""
|
||||
Get the base model from the given model name.
|
||||
|
||||
Handle model names like:
|
||||
- "us.meta.llama3-2-11b-instruct-v1:0" -> "meta.llama3-2-11b-instruct-v1"
|
||||
- "bedrock/converse/model" -> "model"
|
||||
"""
|
||||
model = strip_bedrock_routing_prefix(model)
|
||||
model = extract_model_name_from_bedrock_arn(model)
|
||||
|
||||
potential_region = model.split(".", 1)[0]
|
||||
alt_potential_region = model.split("/", 1)[0]
|
||||
|
||||
if potential_region in get_bedrock_cross_region_inference_regions():
|
||||
return model.split(".", 1)[1]
|
||||
elif (
|
||||
alt_potential_region in _get_all_bedrock_regions()
|
||||
and len(model.split("/", 1)) > 1
|
||||
):
|
||||
return model.split("/", 1)[1]
|
||||
|
||||
return model
|
||||
|
||||
|
||||
# Import after standalone functions to avoid circular imports
|
||||
from litellm.llms.bedrock.count_tokens.bedrock_token_counter import BedrockTokenCounter
|
||||
|
||||
|
||||
class BedrockModelInfo(BaseLLMModelInfo):
|
||||
global_config = AmazonBedrockGlobalConfig()
|
||||
all_global_regions = global_config.get_all_regions()
|
||||
|
|
@ -394,76 +458,34 @@ class BedrockModelInfo(BaseLLMModelInfo):
|
|||
) -> List[str]:
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def extract_model_name_from_arn(model: str) -> str:
|
||||
def get_token_counter(self) -> Optional[BaseTokenCounter]:
|
||||
"""
|
||||
Extract the model name from an AWS Bedrock ARN.
|
||||
Returns the string after the last '/' if 'arn' is in the input string.
|
||||
|
||||
Args:
|
||||
arn (str): The ARN string to parse
|
||||
Factory method to create a Bedrock token counter.
|
||||
|
||||
Returns:
|
||||
str: The extracted model name if 'arn' is in the string,
|
||||
otherwise returns the original string
|
||||
BedrockTokenCounter instance for this provider.
|
||||
"""
|
||||
if "arn" in model.lower():
|
||||
return model.split("/")[-1]
|
||||
return model
|
||||
return BedrockTokenCounter()
|
||||
|
||||
@staticmethod
|
||||
def extract_model_name_from_arn(model: str) -> str:
|
||||
"""Wrapper for standalone function. See extract_model_name_from_bedrock_arn()."""
|
||||
return extract_model_name_from_bedrock_arn(model)
|
||||
|
||||
@staticmethod
|
||||
def get_non_litellm_routing_model_name(model: str) -> str:
|
||||
if model.startswith("bedrock/"):
|
||||
model = model.split("/", 1)[1]
|
||||
|
||||
if model.startswith("converse/"):
|
||||
model = model.split("/", 1)[1]
|
||||
|
||||
if model.startswith("invoke/"):
|
||||
model = model.split("/", 1)[1]
|
||||
|
||||
if model.startswith("openai/"):
|
||||
model = model.split("/", 1)[1]
|
||||
|
||||
return model
|
||||
"""Wrapper for standalone function. See strip_bedrock_routing_prefix()."""
|
||||
return strip_bedrock_routing_prefix(model)
|
||||
|
||||
@staticmethod
|
||||
def get_base_model(model: str) -> str:
|
||||
"""
|
||||
Get the base model from the given model name.
|
||||
|
||||
Handle model names like - "us.meta.llama3-2-11b-instruct-v1:0" -> "meta.llama3-2-11b-instruct-v1"
|
||||
AND "meta.llama3-2-11b-instruct-v1:0" -> "meta.llama3-2-11b-instruct-v1"
|
||||
"""
|
||||
|
||||
model = BedrockModelInfo.get_non_litellm_routing_model_name(model=model)
|
||||
model = BedrockModelInfo.extract_model_name_from_arn(model)
|
||||
|
||||
potential_region = model.split(".", 1)[0]
|
||||
|
||||
alt_potential_region = model.split("/", 1)[
|
||||
0
|
||||
] # in model cost map we store regional information like `/us-west-2/bedrock-model`
|
||||
|
||||
if (
|
||||
potential_region
|
||||
in BedrockModelInfo._supported_cross_region_inference_region()
|
||||
):
|
||||
return model.split(".", 1)[1]
|
||||
elif (
|
||||
alt_potential_region in BedrockModelInfo.all_global_regions
|
||||
and len(model.split("/", 1)) > 1
|
||||
):
|
||||
return model.split("/", 1)[1]
|
||||
|
||||
return model
|
||||
"""Wrapper for standalone function. See get_bedrock_base_model()."""
|
||||
return get_bedrock_base_model(model)
|
||||
|
||||
@staticmethod
|
||||
def _supported_cross_region_inference_region() -> List[str]:
|
||||
"""
|
||||
Abbreviations of regions AWS Bedrock supports for cross region inference
|
||||
"""
|
||||
return ["global", "us", "eu", "apac", "jp", "au", "us-gov"]
|
||||
"""Wrapper for standalone function. See get_bedrock_cross_region_inference_regions()."""
|
||||
return get_bedrock_cross_region_inference_regions()
|
||||
|
||||
@staticmethod
|
||||
def get_bedrock_route(
|
||||
|
|
|
|||
87
litellm/llms/bedrock/count_tokens/bedrock_token_counter.py
Normal file
87
litellm/llms/bedrock/count_tokens/bedrock_token_counter.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"""
|
||||
Bedrock Token Counter implementation using the CountTokens API.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.llms.base_llm.base_utils import BaseTokenCounter
|
||||
from litellm.llms.bedrock.common_utils import get_bedrock_base_model
|
||||
from litellm.llms.bedrock.count_tokens.handler import BedrockCountTokensHandler
|
||||
from litellm.types.utils import LlmProviders, TokenCountResponse
|
||||
|
||||
|
||||
class BedrockTokenCounter(BaseTokenCounter):
|
||||
"""Token counter implementation for AWS Bedrock provider using the CountTokens API."""
|
||||
|
||||
def should_use_token_counting_api(
|
||||
self,
|
||||
custom_llm_provider: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Returns True if we should use the Bedrock CountTokens API for token counting.
|
||||
"""
|
||||
return custom_llm_provider == LlmProviders.BEDROCK.value
|
||||
|
||||
async def count_tokens(
|
||||
self,
|
||||
model_to_use: str,
|
||||
messages: Optional[List[Dict[str, Any]]],
|
||||
contents: Optional[List[Dict[str, Any]]],
|
||||
deployment: Optional[Dict[str, Any]] = None,
|
||||
request_model: str = "",
|
||||
) -> Optional[TokenCountResponse]:
|
||||
"""
|
||||
Count tokens using AWS Bedrock's CountTokens API.
|
||||
|
||||
This method calls the existing BedrockCountTokensHandler to make an API call
|
||||
to Bedrock's token counting endpoint, bypassing the local tiktoken-based counting.
|
||||
|
||||
Args:
|
||||
model_to_use: The model identifier
|
||||
messages: The messages to count tokens for
|
||||
contents: Alternative content format (not used for Bedrock)
|
||||
deployment: Deployment configuration containing litellm_params
|
||||
request_model: The original request model name
|
||||
|
||||
Returns:
|
||||
TokenCountResponse with token count, or None if counting fails
|
||||
"""
|
||||
if not messages:
|
||||
return None
|
||||
|
||||
deployment = deployment or {}
|
||||
litellm_params = deployment.get("litellm_params", {})
|
||||
|
||||
# Build request data in the format expected by BedrockCountTokensHandler
|
||||
request_data = {
|
||||
"model": model_to_use,
|
||||
"messages": messages,
|
||||
}
|
||||
|
||||
# Get the resolved model (strip prefixes like bedrock/, converse/, etc.)
|
||||
resolved_model = get_bedrock_base_model(model_to_use)
|
||||
|
||||
try:
|
||||
handler = BedrockCountTokensHandler()
|
||||
result = await handler.handle_count_tokens_request(
|
||||
request_data=request_data,
|
||||
litellm_params=litellm_params,
|
||||
resolved_model=resolved_model,
|
||||
)
|
||||
|
||||
# Transform response to TokenCountResponse
|
||||
if result is not None:
|
||||
return TokenCountResponse(
|
||||
total_tokens=result.get("input_tokens", 0),
|
||||
request_model=request_model,
|
||||
model_used=model_to_use,
|
||||
tokenizer_type="bedrock_api",
|
||||
original_response=result,
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_logger.warning(
|
||||
f"Error calling Bedrock CountTokens API: {e}, falling back to default tokenizer"
|
||||
)
|
||||
|
||||
return None
|
||||
|
|
@ -70,6 +70,8 @@ class BedrockCountTokensHandler(BedrockCountTokensConfig):
|
|||
verbose_logger.debug(f"Making request to: {endpoint_url}")
|
||||
|
||||
# Use existing _sign_request method from BaseAWSLLM
|
||||
# Extract api_key for bearer token auth if provided
|
||||
api_key = litellm_params.get("api_key", None)
|
||||
headers = {"Content-Type": "application/json"}
|
||||
signed_headers, signed_body = self._sign_request(
|
||||
service_name="bedrock",
|
||||
|
|
@ -78,6 +80,7 @@ class BedrockCountTokensHandler(BedrockCountTokensConfig):
|
|||
request_data=bedrock_request,
|
||||
api_base=endpoint_url,
|
||||
model=resolved_model,
|
||||
api_key=api_key,
|
||||
)
|
||||
|
||||
async_client = get_async_httpx_client(llm_provider=litellm.LlmProviders.BEDROCK)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ to AWS Bedrock's CountTokens API format and vice versa.
|
|||
from typing import Any, Dict, List
|
||||
|
||||
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
|
||||
from litellm.llms.bedrock.common_utils import BedrockModelInfo
|
||||
from litellm.llms.bedrock.common_utils import get_bedrock_base_model
|
||||
|
||||
|
||||
class BedrockCountTokensConfig(BaseAWSLLM):
|
||||
|
|
@ -141,7 +141,7 @@ class BedrockCountTokensConfig(BaseAWSLLM):
|
|||
Complete endpoint URL for CountTokens API
|
||||
"""
|
||||
# Use existing LiteLLM function to get the base model ID (removes region prefix)
|
||||
model_id = BedrockModelInfo.get_base_model(model)
|
||||
model_id = get_bedrock_base_model(model)
|
||||
|
||||
# Remove bedrock/ prefix if present
|
||||
if model_id.startswith("bedrock/"):
|
||||
|
|
|
|||
|
|
@ -150,6 +150,15 @@ def get_api_key_from_env() -> Optional[str]:
|
|||
return get_secret_str("GOOGLE_API_KEY") or get_secret_str("GEMINI_API_KEY")
|
||||
|
||||
|
||||
def get_vertex_api_key_from_env() -> Optional[str]:
|
||||
"""
|
||||
Get API key from environment for Vertex AI.
|
||||
Checks VERTEXAI_API_KEY and VERTEX_API_KEY environment variables.
|
||||
This allows using Vertex AI with API keys instead of service account credentials.
|
||||
"""
|
||||
return get_secret_str("VERTEXAI_API_KEY") or get_secret_str("VERTEX_API_KEY")
|
||||
|
||||
|
||||
class GoogleAIStudioTokenCounter(BaseTokenCounter):
|
||||
"""Token counter implementation for Google AI Studio provider."""
|
||||
def should_use_token_counting_api(
|
||||
|
|
|
|||
|
|
@ -61,6 +61,10 @@
|
|||
"max_completion_tokens": "max_tokens"
|
||||
}
|
||||
},
|
||||
"abliteration": {
|
||||
"base_url": "https://api.abliteration.ai/v1",
|
||||
"api_key_env": "ABLITERATION_API_KEY"
|
||||
},
|
||||
"llamagate": {
|
||||
"base_url": "https://api.llamagate.dev/v1",
|
||||
"api_key_env": "LLAMAGATE_API_KEY",
|
||||
|
|
|
|||
182
litellm/llms/openrouter/embedding/transformation.py
Normal file
182
litellm/llms/openrouter/embedding/transformation.py
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
"""
|
||||
OpenRouter Embedding API Configuration.
|
||||
|
||||
This module provides the configuration for OpenRouter's Embedding API.
|
||||
OpenRouter is OpenAI-compatible and supports embeddings via the /v1/embeddings endpoint.
|
||||
|
||||
Docs: https://openrouter.ai/docs
|
||||
"""
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from litellm.llms.base_llm.embedding.transformation import BaseEmbeddingConfig
|
||||
from litellm.types.llms.openai import AllEmbeddingInputValues
|
||||
from litellm.types.utils import EmbeddingResponse
|
||||
from litellm.utils import convert_to_model_response_object
|
||||
|
||||
from ..common_utils import OpenRouterException
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
|
||||
|
||||
LiteLLMLoggingObj = _LiteLLMLoggingObj
|
||||
else:
|
||||
LiteLLMLoggingObj = Any
|
||||
|
||||
|
||||
class OpenrouterEmbeddingConfig(BaseEmbeddingConfig):
|
||||
"""
|
||||
Configuration for OpenRouter's Embedding API.
|
||||
|
||||
Reference: https://openrouter.ai/docs
|
||||
"""
|
||||
|
||||
def validate_environment(
|
||||
self,
|
||||
headers: dict,
|
||||
model: str,
|
||||
messages: list,
|
||||
optional_params: dict,
|
||||
litellm_params: dict,
|
||||
api_key: Optional[str] = None,
|
||||
api_base: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Validate environment and set up headers for OpenRouter API.
|
||||
|
||||
OpenRouter requires:
|
||||
- Authorization header with Bearer token
|
||||
- HTTP-Referer header (site URL)
|
||||
- X-Title header (app name)
|
||||
"""
|
||||
from litellm import get_secret
|
||||
|
||||
# Get OpenRouter-specific headers
|
||||
openrouter_site_url = get_secret("OR_SITE_URL") or "https://litellm.ai"
|
||||
openrouter_app_name = get_secret("OR_APP_NAME") or "liteLLM"
|
||||
|
||||
openrouter_headers = {
|
||||
"HTTP-Referer": openrouter_site_url,
|
||||
"X-Title": openrouter_app_name,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# Add Authorization header if api_key is provided
|
||||
if api_key:
|
||||
openrouter_headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
# Merge with existing headers (user's extra_headers take priority)
|
||||
merged_headers = {**openrouter_headers, **headers}
|
||||
|
||||
return merged_headers
|
||||
|
||||
def get_complete_url(
|
||||
self,
|
||||
api_base: Optional[str],
|
||||
api_key: Optional[str],
|
||||
model: str,
|
||||
optional_params: dict,
|
||||
litellm_params: dict,
|
||||
stream: Optional[bool] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Get the complete URL for OpenRouter Embedding API endpoint.
|
||||
"""
|
||||
# api_base is already set to https://openrouter.ai/api/v1 in main.py
|
||||
# Remove trailing slashes
|
||||
if api_base:
|
||||
api_base = api_base.rstrip("/")
|
||||
else:
|
||||
api_base = "https://openrouter.ai/api/v1"
|
||||
|
||||
# Return the embeddings endpoint
|
||||
return f"{api_base}/embeddings"
|
||||
|
||||
def transform_embedding_request(
|
||||
self,
|
||||
model: str,
|
||||
input: AllEmbeddingInputValues,
|
||||
optional_params: dict,
|
||||
headers: dict,
|
||||
) -> dict:
|
||||
"""
|
||||
Transform embedding request to OpenRouter format (OpenAI-compatible).
|
||||
"""
|
||||
# Ensure input is a list
|
||||
if isinstance(input, str):
|
||||
input = [input]
|
||||
|
||||
# OpenRouter expects the full model name (e.g., google/gemini-embedding-001)
|
||||
# Strip 'openrouter/' prefix if present
|
||||
if model.startswith("openrouter/"):
|
||||
model = model.replace("openrouter/", "", 1)
|
||||
|
||||
return {
|
||||
"model": model,
|
||||
"input": input,
|
||||
**optional_params,
|
||||
}
|
||||
|
||||
def transform_embedding_response(
|
||||
self,
|
||||
model: str,
|
||||
raw_response: httpx.Response,
|
||||
model_response: EmbeddingResponse,
|
||||
logging_obj: LiteLLMLoggingObj,
|
||||
api_key: Optional[str],
|
||||
request_data: dict,
|
||||
optional_params: dict,
|
||||
litellm_params: dict,
|
||||
) -> EmbeddingResponse:
|
||||
"""
|
||||
Transform embedding response from OpenRouter format (OpenAI-compatible).
|
||||
"""
|
||||
logging_obj.post_call(original_response=raw_response.text)
|
||||
|
||||
# OpenRouter returns standard OpenAI-compatible embedding response
|
||||
response_json = raw_response.json()
|
||||
|
||||
return convert_to_model_response_object(
|
||||
response_object=response_json,
|
||||
model_response_object=model_response,
|
||||
response_type="embedding",
|
||||
)
|
||||
|
||||
def get_supported_openai_params(self, model: str) -> list:
|
||||
"""
|
||||
Get list of supported OpenAI parameters for OpenRouter embeddings.
|
||||
"""
|
||||
return [
|
||||
"timeout",
|
||||
"dimensions",
|
||||
"encoding_format",
|
||||
"user",
|
||||
]
|
||||
|
||||
def map_openai_params(
|
||||
self,
|
||||
non_default_params: dict,
|
||||
optional_params: dict,
|
||||
model: str,
|
||||
drop_params: bool,
|
||||
) -> dict:
|
||||
"""
|
||||
Map OpenAI parameters to OpenRouter format.
|
||||
"""
|
||||
for param, value in non_default_params.items():
|
||||
if param in self.get_supported_openai_params(model):
|
||||
optional_params[param] = value
|
||||
return optional_params
|
||||
|
||||
def get_error_class(
|
||||
self, error_message: str, status_code: int, headers: Any
|
||||
) -> Any:
|
||||
"""
|
||||
Get the error class for OpenRouter errors.
|
||||
"""
|
||||
return OpenRouterException(
|
||||
message=error_message,
|
||||
status_code=status_code,
|
||||
headers=headers,
|
||||
)
|
||||
|
|
@ -480,20 +480,21 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
|
|||
or tool_name == VertexToolName.CODE_EXECUTION.value
|
||||
): # code_execution maintained for backwards compatibility
|
||||
code_execution = self.get_tool_value(tool, "codeExecution")
|
||||
elif tool_name and tool_name == VertexToolName.GOOGLE_SEARCH.value:
|
||||
googleSearch = self.get_tool_value(
|
||||
tool, VertexToolName.GOOGLE_SEARCH.value
|
||||
)
|
||||
elif (
|
||||
tool_name and tool_name == VertexToolName.GOOGLE_SEARCH_RETRIEVAL.value
|
||||
elif tool_name and (
|
||||
tool_name == VertexToolName.GOOGLE_SEARCH.value
|
||||
or tool_name == "google_search"
|
||||
):
|
||||
googleSearchRetrieval = self.get_tool_value(
|
||||
tool, VertexToolName.GOOGLE_SEARCH_RETRIEVAL.value
|
||||
)
|
||||
elif tool_name and tool_name == VertexToolName.ENTERPRISE_WEB_SEARCH.value:
|
||||
enterpriseWebSearch = self.get_tool_value(
|
||||
tool, VertexToolName.ENTERPRISE_WEB_SEARCH.value
|
||||
)
|
||||
googleSearch = self.get_tool_value(tool, tool_name)
|
||||
elif tool_name and (
|
||||
tool_name == VertexToolName.GOOGLE_SEARCH_RETRIEVAL.value
|
||||
or tool_name == "google_search_retrieval"
|
||||
):
|
||||
googleSearchRetrieval = self.get_tool_value(tool, tool_name)
|
||||
elif tool_name and (
|
||||
tool_name == VertexToolName.ENTERPRISE_WEB_SEARCH.value
|
||||
or tool_name == "enterprise_web_search"
|
||||
):
|
||||
enterpriseWebSearch = self.get_tool_value(tool, tool_name)
|
||||
elif tool_name and (
|
||||
tool_name == VertexToolName.URL_CONTEXT.value
|
||||
or tool_name == "urlContext"
|
||||
|
|
@ -1811,6 +1812,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
|
|||
functions: Optional[ChatCompletionToolCallFunctionChunk] = None
|
||||
thinking_blocks: Optional[List[ChatCompletionThinkingBlock]] = None
|
||||
reasoning_content: Optional[str] = None
|
||||
thought_signatures: Optional[Any] = None
|
||||
|
||||
for idx, candidate in enumerate(_candidates):
|
||||
if "content" not in candidate:
|
||||
|
|
|
|||
|
|
@ -388,6 +388,10 @@ class VertexBase:
|
|||
Internal function. Returns the token and url for the call.
|
||||
|
||||
Handles logic if it's google ai studio vs. vertex ai.
|
||||
|
||||
For Vertex AI:
|
||||
- If gemini_api_key is provided, use API key authentication (x-goog-api-key header)
|
||||
- Otherwise, use service account credentials (OAuth2 Bearer token)
|
||||
|
||||
Returns
|
||||
token, url
|
||||
|
|
@ -400,7 +404,7 @@ class VertexBase:
|
|||
stream=stream,
|
||||
gemini_api_key=gemini_api_key,
|
||||
)
|
||||
auth_header = None # this field is not used for gemin
|
||||
auth_header = None # this field is not used for gemini
|
||||
else:
|
||||
vertex_location = self.get_vertex_region(
|
||||
vertex_region=vertex_location,
|
||||
|
|
@ -409,14 +413,32 @@ class VertexBase:
|
|||
|
||||
### SET RUNTIME ENDPOINT ###
|
||||
version = "v1beta1" if should_use_v1beta1_features is True else "v1"
|
||||
url, endpoint = _get_vertex_url(
|
||||
mode=mode,
|
||||
model=model,
|
||||
stream=stream,
|
||||
vertex_project=vertex_project,
|
||||
vertex_location=vertex_location,
|
||||
vertex_api_version=version,
|
||||
)
|
||||
|
||||
# Check if using API key authentication for Vertex AI
|
||||
if gemini_api_key and not vertex_credentials:
|
||||
# When using API key with Vertex AI, use the Google AI Studio endpoint
|
||||
# This is because Vertex AI API keys work with generativelanguage.googleapis.com
|
||||
verbose_logger.debug(
|
||||
f"Using Vertex AI API key authentication for model: {model} - routing to Google AI Studio endpoint"
|
||||
)
|
||||
url, endpoint = _get_gemini_url(
|
||||
mode=mode,
|
||||
model=model,
|
||||
stream=stream,
|
||||
gemini_api_key=gemini_api_key,
|
||||
)
|
||||
# API key is already included in the URL by _get_gemini_url
|
||||
auth_header = None
|
||||
else:
|
||||
# Use OAuth2 Bearer token authentication (traditional Vertex AI)
|
||||
url, endpoint = _get_vertex_url(
|
||||
mode=mode,
|
||||
model=model,
|
||||
stream=stream,
|
||||
vertex_project=vertex_project,
|
||||
vertex_location=vertex_location,
|
||||
vertex_api_version=version,
|
||||
)
|
||||
|
||||
return self._check_custom_proxy(
|
||||
api_base=api_base,
|
||||
|
|
|
|||
|
|
@ -189,7 +189,7 @@ from .llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler
|
|||
from .llms.custom_llm import CustomLLM, custom_chat_llm_router
|
||||
from .llms.databricks.embed.handler import DatabricksEmbeddingHandler
|
||||
from .llms.deprecated_providers import aleph_alpha, palm
|
||||
from .llms.gemini.common_utils import get_api_key_from_env
|
||||
from .llms.gemini.common_utils import get_api_key_from_env, get_vertex_api_key_from_env
|
||||
from .llms.groq.chat.handler import GroqChatCompletion
|
||||
from .llms.heroku.chat.transformation import HerokuChatConfig
|
||||
from .llms.huggingface.embedding.handler import HuggingFaceEmbedding
|
||||
|
|
@ -3230,6 +3230,12 @@ def completion( # type: ignore # noqa: PLR0915
|
|||
or get_secret("VERTEXAI_CREDENTIALS")
|
||||
)
|
||||
|
||||
vertex_api_key = (
|
||||
api_key
|
||||
or get_vertex_api_key_from_env()
|
||||
or litellm.api_key
|
||||
)
|
||||
|
||||
api_base = api_base or litellm.api_base or get_secret("VERTEXAI_API_BASE")
|
||||
|
||||
new_params = safe_deep_copy(optional_params or {})
|
||||
|
|
@ -3271,7 +3277,7 @@ def completion( # type: ignore # noqa: PLR0915
|
|||
vertex_location=vertex_ai_location,
|
||||
vertex_project=vertex_ai_project,
|
||||
vertex_credentials=vertex_credentials,
|
||||
gemini_api_key=None,
|
||||
gemini_api_key=vertex_api_key, # Support for Vertex AI API Key
|
||||
logging_obj=logging,
|
||||
acompletion=acompletion,
|
||||
timeout=timeout,
|
||||
|
|
@ -4701,6 +4707,51 @@ def embedding( # noqa: PLR0915
|
|||
litellm_params=litellm_params_dict,
|
||||
headers=headers,
|
||||
)
|
||||
elif custom_llm_provider == "openrouter":
|
||||
api_base = (
|
||||
api_base
|
||||
or litellm.api_base
|
||||
or get_secret_str("OPENROUTER_API_BASE")
|
||||
or "https://openrouter.ai/api/v1"
|
||||
)
|
||||
|
||||
api_key = (
|
||||
api_key
|
||||
or litellm.api_key
|
||||
or litellm.openrouter_key
|
||||
or get_secret("OPENROUTER_API_KEY")
|
||||
or get_secret("OR_API_KEY")
|
||||
)
|
||||
|
||||
openrouter_site_url = get_secret("OR_SITE_URL") or "https://litellm.ai"
|
||||
openrouter_app_name = get_secret("OR_APP_NAME") or "liteLLM"
|
||||
|
||||
openrouter_headers = {
|
||||
"HTTP-Referer": openrouter_site_url,
|
||||
"X-Title": openrouter_app_name,
|
||||
}
|
||||
|
||||
_headers = headers or litellm.headers
|
||||
if _headers:
|
||||
openrouter_headers.update(_headers)
|
||||
|
||||
headers = openrouter_headers
|
||||
|
||||
response = base_llm_http_handler.embedding(
|
||||
model=model,
|
||||
input=input,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
api_base=api_base,
|
||||
api_key=api_key,
|
||||
logging_obj=logging,
|
||||
timeout=timeout,
|
||||
model_response=EmbeddingResponse(),
|
||||
optional_params=optional_params,
|
||||
client=client,
|
||||
aembedding=aembedding,
|
||||
litellm_params=litellm_params_dict,
|
||||
headers=headers,
|
||||
)
|
||||
elif custom_llm_provider == "huggingface":
|
||||
api_key = (
|
||||
api_key
|
||||
|
|
|
|||
|
|
@ -1942,7 +1942,7 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
|
|||
description="connect to a postgres db - needed for generating temporary keys + tracking spend / key",
|
||||
)
|
||||
database_connection_pool_limit: Optional[int] = Field(
|
||||
100,
|
||||
10,
|
||||
description="default connection pool for prisma client connecting to postgres db",
|
||||
)
|
||||
database_connection_timeout: Optional[float] = Field(
|
||||
|
|
@ -2104,6 +2104,7 @@ class LiteLLM_VerificationToken(LiteLLMPydanticObjectBase):
|
|||
rotation_interval: Optional[str] = None # How often to rotate (e.g., "30d", "90d")
|
||||
last_rotation_at: Optional[datetime] = None # When this key was last rotated
|
||||
key_rotation_at: Optional[datetime] = None # When this key should next be rotated
|
||||
router_settings: Optional[Dict] = None # Router settings for this key (Key > Team > Global precedence)
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
|||
from litellm.integrations.custom_guardrail import ModifyResponseException
|
||||
from litellm.proxy.common_request_processing import (
|
||||
ProxyBaseLLMRequestProcessing,
|
||||
create_streaming_response,
|
||||
create_response,
|
||||
)
|
||||
from litellm.proxy.common_utils.http_parsing_utils import _read_request_body
|
||||
from litellm.types.utils import TokenCountResponse
|
||||
|
|
@ -106,7 +106,7 @@ async def anthropic_response( # noqa: PLR0915
|
|||
)
|
||||
)
|
||||
|
||||
return await create_streaming_response(
|
||||
return await create_response(
|
||||
generator=selected_data_generator,
|
||||
media_type="text/event-stream",
|
||||
headers={},
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ def _apply_budget_limits_to_end_user_params(
|
|||
) -> None:
|
||||
"""
|
||||
Helper function to apply budget limits to end user parameters.
|
||||
|
||||
|
||||
Args:
|
||||
end_user_params: Dictionary to update with budget parameters
|
||||
budget_info: Budget table object containing limits
|
||||
|
|
@ -146,16 +146,14 @@ def _apply_budget_limits_to_end_user_params(
|
|||
"""
|
||||
if budget_info.tpm_limit is not None:
|
||||
end_user_params["end_user_tpm_limit"] = budget_info.tpm_limit
|
||||
|
||||
|
||||
if budget_info.rpm_limit is not None:
|
||||
end_user_params["end_user_rpm_limit"] = budget_info.rpm_limit
|
||||
|
||||
|
||||
if budget_info.max_budget is not None:
|
||||
end_user_params["end_user_max_budget"] = budget_info.max_budget
|
||||
|
||||
verbose_proxy_logger.debug(
|
||||
f"Applied budget limits to end user {end_user_id}"
|
||||
)
|
||||
|
||||
verbose_proxy_logger.debug(f"Applied budget limits to end user {end_user_id}")
|
||||
|
||||
|
||||
async def user_api_key_auth_websocket(websocket: WebSocket):
|
||||
|
|
@ -170,12 +168,10 @@ async def user_api_key_auth_websocket(websocket: WebSocket):
|
|||
|
||||
model = query_params.get("model")
|
||||
|
||||
|
||||
async def return_body():
|
||||
return _realtime_request_body(model)
|
||||
|
||||
request.body = return_body # type: ignore
|
||||
|
||||
request.body = return_body # type: ignore
|
||||
|
||||
authorization = websocket.headers.get("authorization")
|
||||
# If no Authorization header, try the api-key header
|
||||
|
|
@ -586,7 +582,9 @@ async def _user_api_key_auth_builder( # noqa: PLR0915
|
|||
if team_membership is not None
|
||||
else None
|
||||
),
|
||||
team_metadata=team_object.metadata if team_object is not None else None,
|
||||
team_metadata=team_object.metadata
|
||||
if team_object is not None
|
||||
else None,
|
||||
)
|
||||
# run through common checks
|
||||
_ = await common_checks(
|
||||
|
|
@ -669,9 +667,9 @@ async def _user_api_key_auth_builder( # noqa: PLR0915
|
|||
route=route,
|
||||
)
|
||||
if _end_user_object is not None:
|
||||
end_user_params["allowed_model_region"] = (
|
||||
_end_user_object.allowed_model_region
|
||||
)
|
||||
end_user_params[
|
||||
"allowed_model_region"
|
||||
] = _end_user_object.allowed_model_region
|
||||
if _end_user_object.litellm_budget_table is not None:
|
||||
_apply_budget_limits_to_end_user_params(
|
||||
end_user_params=end_user_params,
|
||||
|
|
@ -753,7 +751,7 @@ async def _user_api_key_auth_builder( # noqa: PLR0915
|
|||
message=f"Authentication Error - Expired Key. Key Expiry time {expiry_time} and current time {current_time}",
|
||||
type=ProxyErrorTypes.expired_key,
|
||||
code=400,
|
||||
param=api_key,
|
||||
param=abbreviate_api_key(api_key=api_key),
|
||||
)
|
||||
valid_token = update_valid_token_with_end_user_params(
|
||||
valid_token=valid_token, end_user_params=end_user_params
|
||||
|
|
@ -994,7 +992,6 @@ async def _user_api_key_auth_builder( # noqa: PLR0915
|
|||
|
||||
# Check 3. Check if user is in their team budget
|
||||
if valid_token.team_member_spend is not None:
|
||||
|
||||
if prisma_client is not None:
|
||||
_cache_key = f"{valid_token.team_id}_{valid_token.user_id}"
|
||||
|
||||
|
|
@ -1055,7 +1052,7 @@ async def _user_api_key_auth_builder( # noqa: PLR0915
|
|||
message=f"Authentication Error - Expired Key. Key Expiry time {expiry_time} and current time {current_time}",
|
||||
type=ProxyErrorTypes.expired_key,
|
||||
code=400,
|
||||
param=api_key,
|
||||
param=abbreviate_api_key(api_key=api_key),
|
||||
)
|
||||
|
||||
# Check 4. Token Spend is under budget
|
||||
|
|
@ -1216,8 +1213,6 @@ async def _user_api_key_auth_builder( # noqa: PLR0915
|
|||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@tracer.wrap()
|
||||
async def user_api_key_auth(
|
||||
request: Request,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ from typing import (
|
|||
import httpx
|
||||
import orjson
|
||||
from fastapi import HTTPException, Request, status
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
|
|
@ -96,16 +96,55 @@ async def _parse_event_data_for_error(event_line: Union[str, bytes]) -> Optional
|
|||
return None
|
||||
|
||||
|
||||
async def create_streaming_response(
|
||||
def _extract_error_from_sse_chunk(event_line: Union[str, bytes]) -> dict:
|
||||
"""
|
||||
Extract error dictionary from SSE format chunk.
|
||||
|
||||
Args:
|
||||
event_line: SSE format event line, e.g. "data: {"error": {...}}\n\n"
|
||||
|
||||
Returns:
|
||||
Error dictionary in OpenAI API format
|
||||
"""
|
||||
event_line = (
|
||||
event_line.decode("utf-8") if isinstance(event_line, bytes) else event_line
|
||||
)
|
||||
|
||||
# Default error format
|
||||
default_error = {
|
||||
"message": "Unknown error",
|
||||
"type": "internal_server_error",
|
||||
"param": None,
|
||||
"code": "500",
|
||||
}
|
||||
|
||||
if event_line.startswith("data: "):
|
||||
json_str = event_line[len("data: ") :].strip()
|
||||
if not json_str or json_str == "[DONE]":
|
||||
return default_error
|
||||
|
||||
try:
|
||||
data = orjson.loads(json_str)
|
||||
if isinstance(data, dict) and "error" in data:
|
||||
error_obj = data["error"]
|
||||
if isinstance(error_obj, dict):
|
||||
return error_obj
|
||||
except (orjson.JSONDecodeError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
return default_error
|
||||
|
||||
|
||||
async def create_response(
|
||||
generator: AsyncGenerator[str, None],
|
||||
media_type: str,
|
||||
headers: dict,
|
||||
default_status_code: int = status.HTTP_200_OK,
|
||||
) -> StreamingResponse:
|
||||
) -> Union[StreamingResponse, JSONResponse]:
|
||||
"""
|
||||
Creates a StreamingResponse by inspecting the first chunk for an error code.
|
||||
The entire original generator content is streamed, but the HTTP status code
|
||||
of the response is set based on the first chunk if it's a recognized error.
|
||||
Create streaming response, checking if the first chunk is an error.
|
||||
If the first chunk is an error, return a standard JSON error response.
|
||||
Otherwise, return StreamingResponse and stream all content.
|
||||
"""
|
||||
first_chunk_value: Optional[str] = None
|
||||
final_status_code = default_status_code
|
||||
|
|
@ -124,9 +163,27 @@ async def create_streaming_response(
|
|||
first_chunk_value
|
||||
)
|
||||
if error_code_from_chunk is not None:
|
||||
# First chunk is an error, stream hasn't really started yet
|
||||
# Should return standard JSON error response instead of SSE format
|
||||
final_status_code = error_code_from_chunk
|
||||
verbose_proxy_logger.debug(
|
||||
f"Error detected in first stream chunk. Status code set to: {final_status_code}"
|
||||
f"Error detected in first stream chunk. Returning JSON error response with status code: {final_status_code}"
|
||||
)
|
||||
|
||||
# Parse error content
|
||||
error_dict = _extract_error_from_sse_chunk(first_chunk_value)
|
||||
|
||||
# Consume and close generator (avoid resource leak)
|
||||
try:
|
||||
await generator.aclose()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Return JSON format error response
|
||||
return JSONResponse(
|
||||
status_code=final_status_code,
|
||||
content={"error": error_dict},
|
||||
headers=headers,
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.debug(f"Error parsing first chunk value: {e}")
|
||||
|
|
@ -237,7 +294,11 @@ class ProxyBaseLLMRequestProcessing:
|
|||
if response_cost is not None:
|
||||
try:
|
||||
# Convert response_cost to float if it's a string
|
||||
cost_value = float(response_cost) if isinstance(response_cost, str) else response_cost
|
||||
cost_value = (
|
||||
float(response_cost)
|
||||
if isinstance(response_cost, str)
|
||||
else response_cost
|
||||
)
|
||||
if cost_value > 0:
|
||||
updated_spend = current_spend + cost_value
|
||||
except (ValueError, TypeError):
|
||||
|
|
@ -376,6 +437,16 @@ class ProxyBaseLLMRequestProcessing:
|
|||
) -> Tuple[dict, LiteLLMLoggingObj]:
|
||||
start_time = datetime.now() # start before calling guardrail hooks
|
||||
|
||||
# Calculate request queue time if arrival_time is available
|
||||
# Use start_time.timestamp() to avoid extra time.time() call for better performance
|
||||
proxy_server_request = self.data.get("proxy_server_request", {})
|
||||
arrival_time = proxy_server_request.get("arrival_time")
|
||||
queue_time_seconds = None
|
||||
if arrival_time is not None:
|
||||
# Convert start_time (datetime) to timestamp for calculation
|
||||
processing_start_time = start_time.timestamp()
|
||||
queue_time_seconds = processing_start_time - arrival_time
|
||||
|
||||
self.data = await add_litellm_data_to_request(
|
||||
data=self.data,
|
||||
request=request,
|
||||
|
|
@ -385,6 +456,19 @@ class ProxyBaseLLMRequestProcessing:
|
|||
proxy_config=proxy_config,
|
||||
)
|
||||
|
||||
# Store queue time in metadata after add_litellm_data_to_request to ensure it's preserved
|
||||
if queue_time_seconds is not None:
|
||||
from litellm.proxy.litellm_pre_call_utils import _get_metadata_variable_name
|
||||
|
||||
_metadata_variable_name = _get_metadata_variable_name(request)
|
||||
if _metadata_variable_name not in self.data:
|
||||
self.data[_metadata_variable_name] = {}
|
||||
if not isinstance(self.data[_metadata_variable_name], dict):
|
||||
self.data[_metadata_variable_name] = {}
|
||||
self.data[_metadata_variable_name][
|
||||
"queue_time_seconds"
|
||||
] = queue_time_seconds
|
||||
|
||||
self.data["model"] = (
|
||||
general_settings.get("completion_model", None) # server default
|
||||
or user_model # model name passed via cli args
|
||||
|
|
@ -440,6 +524,29 @@ class ProxyBaseLLMRequestProcessing:
|
|||
user_api_key_dict=user_api_key_dict, data=self.data, call_type=route_type # type: ignore
|
||||
)
|
||||
|
||||
# Apply hierarchical router_settings (Key > Team > Global)
|
||||
if llm_router is not None and proxy_config is not None:
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
router_settings = await proxy_config._get_hierarchical_router_settings(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
prisma_client=prisma_client,
|
||||
)
|
||||
|
||||
# If router_settings found (from key, team, or global), apply them
|
||||
# This ensures key/team settings override global settings
|
||||
if router_settings is not None and router_settings:
|
||||
# Get model_list from current router
|
||||
model_list = llm_router.get_model_list()
|
||||
if model_list is not None:
|
||||
# Create user_config with model_list and router_settings
|
||||
# This creates a per-request router with the hierarchical settings
|
||||
user_config = {
|
||||
"model_list": model_list,
|
||||
**router_settings
|
||||
}
|
||||
self.data["user_config"] = user_config
|
||||
|
||||
if "messages" in self.data and self.data["messages"]:
|
||||
logging_obj.update_messages(self.data["messages"])
|
||||
|
||||
|
|
@ -647,7 +754,7 @@ class ProxyBaseLLMRequestProcessing:
|
|||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
)
|
||||
return await create_streaming_response(
|
||||
return await create_response(
|
||||
generator=selected_data_generator,
|
||||
media_type="text/event-stream",
|
||||
headers=custom_headers,
|
||||
|
|
@ -658,7 +765,7 @@ class ProxyBaseLLMRequestProcessing:
|
|||
user_api_key_dict=user_api_key_dict,
|
||||
request_data=self.data,
|
||||
)
|
||||
return await create_streaming_response(
|
||||
return await create_response(
|
||||
generator=selected_data_generator,
|
||||
media_type="text/event-stream",
|
||||
headers=custom_headers,
|
||||
|
|
@ -900,11 +1007,11 @@ class ProxyBaseLLMRequestProcessing:
|
|||
@staticmethod
|
||||
def _get_pre_call_type(
|
||||
route_type: Literal["acompletion", "aembedding", "aresponses", "allm_passthrough_route"],
|
||||
) -> Literal["completion", "embeddings", "responses", "allm_passthrough_route"]:
|
||||
) -> Literal["completion", "embedding", "responses", "allm_passthrough_route"]:
|
||||
if route_type == "acompletion":
|
||||
return "completion"
|
||||
elif route_type == "aembedding":
|
||||
return "embeddings"
|
||||
return "embedding"
|
||||
elif route_type == "aresponses":
|
||||
return "responses"
|
||||
elif route_type == "allm_passthrough_route":
|
||||
|
|
@ -1155,9 +1262,9 @@ class ProxyBaseLLMRequestProcessing:
|
|||
|
||||
# Add cache-related fields to **params (handled by Usage.__init__)
|
||||
if cache_creation_input_tokens is not None:
|
||||
usage_kwargs["cache_creation_input_tokens"] = (
|
||||
cache_creation_input_tokens
|
||||
)
|
||||
usage_kwargs[
|
||||
"cache_creation_input_tokens"
|
||||
] = cache_creation_input_tokens
|
||||
if cache_read_input_tokens is not None:
|
||||
usage_kwargs["cache_read_input_tokens"] = cache_read_input_tokens
|
||||
|
||||
|
|
|
|||
|
|
@ -17,50 +17,141 @@ from litellm.secret_managers.main import str_to_bool
|
|||
|
||||
|
||||
class PrismaWrapper:
|
||||
"""
|
||||
Wrapper around Prisma client that handles RDS IAM token authentication.
|
||||
|
||||
When iam_token_db_auth is enabled, this wrapper:
|
||||
1. Proactively refreshes IAM tokens before they expire (background task)
|
||||
2. Falls back to synchronous refresh if a token is found expired
|
||||
3. Uses proper locking to prevent race conditions during reconnection
|
||||
|
||||
RDS IAM tokens are valid for 15 minutes. This wrapper refreshes them
|
||||
3 minutes before expiration to ensure uninterrupted database connectivity.
|
||||
"""
|
||||
|
||||
# Buffer time in seconds before token expiration to trigger refresh
|
||||
# Refresh 3 minutes (180 seconds) before the token expires
|
||||
TOKEN_REFRESH_BUFFER_SECONDS = 180
|
||||
|
||||
# Fallback refresh interval if token parsing fails (10 minutes)
|
||||
FALLBACK_REFRESH_INTERVAL_SECONDS = 600
|
||||
|
||||
def __init__(self, original_prisma: Any, iam_token_db_auth: bool):
|
||||
self._original_prisma = original_prisma
|
||||
self.iam_token_db_auth = iam_token_db_auth
|
||||
|
||||
# Background token refresh task management
|
||||
self._token_refresh_task: Optional[asyncio.Task] = None
|
||||
self._reconnection_lock = asyncio.Lock()
|
||||
self._last_refresh_time: Optional[datetime] = None
|
||||
|
||||
def _extract_token_from_db_url(self, db_url: Optional[str]) -> Optional[str]:
|
||||
"""
|
||||
Extract the token (password) from the DATABASE_URL.
|
||||
|
||||
The token contains the AWS signature with X-Amz-Date and X-Amz-Expires parameters.
|
||||
|
||||
Important: We must parse the URL while it's still encoded to preserve structure,
|
||||
then decode the password portion. Otherwise the '?' in the token breaks URL parsing.
|
||||
"""
|
||||
if db_url is None:
|
||||
return None
|
||||
try:
|
||||
# Parse URL while still encoded to preserve structure
|
||||
parsed = urllib.parse.urlparse(db_url)
|
||||
if parsed.password:
|
||||
# Now decode just the password/token
|
||||
return urllib.parse.unquote(parsed.password)
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _parse_token_expiration(self, token: Optional[str]) -> Optional[datetime]:
|
||||
"""
|
||||
Parse the token to extract its expiration time.
|
||||
|
||||
Returns the datetime when the token expires, or None if parsing fails.
|
||||
"""
|
||||
if token is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Token format: ...?X-Amz-Date=YYYYMMDDTHHMMSSZ&X-Amz-Expires=900&...
|
||||
if "?" not in token:
|
||||
return None
|
||||
|
||||
query_string = token.split("?", 1)[1]
|
||||
params = urllib.parse.parse_qs(query_string)
|
||||
|
||||
expires_str = params.get("X-Amz-Expires", [None])[0]
|
||||
date_str = params.get("X-Amz-Date", [None])[0]
|
||||
|
||||
if not expires_str or not date_str:
|
||||
return None
|
||||
|
||||
token_created = datetime.strptime(date_str, "%Y%m%dT%H%M%SZ")
|
||||
expires_in = int(expires_str)
|
||||
|
||||
return token_created + timedelta(seconds=expires_in)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.debug(f"Failed to parse token expiration: {e}")
|
||||
return None
|
||||
|
||||
def _calculate_seconds_until_refresh(self) -> float:
|
||||
"""
|
||||
Calculate exactly how many seconds until we need to refresh the token.
|
||||
|
||||
Uses precise timing: sleeps until (token_expiration - buffer_seconds).
|
||||
For a 15-minute (900s) token with 180s buffer, this returns ~720s (12 min).
|
||||
|
||||
Returns:
|
||||
Number of seconds to sleep before the next refresh.
|
||||
Returns 0 if token should be refreshed immediately.
|
||||
Returns FALLBACK_REFRESH_INTERVAL_SECONDS if parsing fails.
|
||||
"""
|
||||
db_url = os.getenv("DATABASE_URL")
|
||||
token = self._extract_token_from_db_url(db_url)
|
||||
expiration_time = self._parse_token_expiration(token)
|
||||
|
||||
if expiration_time is None:
|
||||
# If we can't parse the token, use fallback interval
|
||||
verbose_proxy_logger.debug(
|
||||
f"Could not parse token expiration, using fallback interval of "
|
||||
f"{self.FALLBACK_REFRESH_INTERVAL_SECONDS}s"
|
||||
)
|
||||
return self.FALLBACK_REFRESH_INTERVAL_SECONDS
|
||||
|
||||
# Calculate when we should refresh (expiration - buffer)
|
||||
refresh_at = expiration_time - timedelta(
|
||||
seconds=self.TOKEN_REFRESH_BUFFER_SECONDS
|
||||
)
|
||||
|
||||
# How long until refresh time?
|
||||
now = datetime.utcnow()
|
||||
seconds_until_refresh = (refresh_at - now).total_seconds()
|
||||
|
||||
# If already past refresh time, return 0 (refresh immediately)
|
||||
return max(0, seconds_until_refresh)
|
||||
|
||||
def is_token_expired(self, token_url: Optional[str]) -> bool:
|
||||
"""Check if the token in the given URL is expired."""
|
||||
if token_url is None:
|
||||
return True
|
||||
# Decode the token URL to handle URL-encoded characters
|
||||
decoded_url = urllib.parse.unquote(token_url)
|
||||
|
||||
# Parse the token URL
|
||||
parsed_url = urllib.parse.urlparse(decoded_url)
|
||||
token = self._extract_token_from_db_url(token_url)
|
||||
expiration_time = self._parse_token_expiration(token)
|
||||
|
||||
# Parse the query parameters from the path component (if they exist there)
|
||||
query_params = urllib.parse.parse_qs(parsed_url.query)
|
||||
if expiration_time is None:
|
||||
# If we can't parse the token, assume it's expired to trigger refresh
|
||||
verbose_proxy_logger.debug(
|
||||
"Could not parse token expiration, treating as expired"
|
||||
)
|
||||
return True
|
||||
|
||||
# Get expiration time from the query parameters
|
||||
expires = query_params.get("X-Amz-Expires", [None])[0]
|
||||
if expires is None:
|
||||
raise ValueError("X-Amz-Expires parameter is missing or invalid.")
|
||||
|
||||
expires_int = int(expires)
|
||||
|
||||
# Get the token's creation time from the X-Amz-Date parameter
|
||||
token_time_str = query_params.get("X-Amz-Date", [""])[0]
|
||||
if not token_time_str:
|
||||
raise ValueError("X-Amz-Date parameter is missing or invalid.")
|
||||
|
||||
# Ensure the token time string is parsed correctly
|
||||
try:
|
||||
token_time = datetime.strptime(token_time_str, "%Y%m%dT%H%M%SZ")
|
||||
except ValueError as e:
|
||||
raise ValueError(f"Invalid X-Amz-Date format: {e}")
|
||||
|
||||
# Calculate the expiration time
|
||||
expiration_time = token_time + timedelta(seconds=expires_int)
|
||||
|
||||
# Current time in UTC
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
# Check if the token is expired
|
||||
return current_time > expiration_time
|
||||
return datetime.utcnow() > expiration_time
|
||||
|
||||
def get_rds_iam_token(self) -> Optional[str]:
|
||||
"""Generate a new RDS IAM token and update DATABASE_URL."""
|
||||
if self.iam_token_db_auth:
|
||||
from litellm.proxy.auth.rds_iam_token import generate_iam_auth_token
|
||||
|
||||
|
|
@ -74,7 +165,6 @@ class PrismaWrapper:
|
|||
db_host=db_host, db_port=db_port, db_user=db_user
|
||||
)
|
||||
|
||||
# print(f"token: {token}")
|
||||
_db_url = f"postgresql://{db_user}:{token}@{db_host}:{db_port}/{db_name}"
|
||||
if db_schema:
|
||||
_db_url += f"?schema={db_schema}"
|
||||
|
|
@ -86,6 +176,7 @@ class PrismaWrapper:
|
|||
async def recreate_prisma_client(
|
||||
self, new_db_url: str, http_client: Optional[Any] = None
|
||||
):
|
||||
"""Disconnect and reconnect the Prisma client with a new database URL."""
|
||||
from prisma import Prisma # type: ignore
|
||||
|
||||
try:
|
||||
|
|
@ -100,21 +191,159 @@ class PrismaWrapper:
|
|||
|
||||
await self._original_prisma.connect()
|
||||
|
||||
async def start_token_refresh_task(self) -> None:
|
||||
"""
|
||||
Start the background token refresh task.
|
||||
|
||||
This task proactively refreshes RDS IAM tokens before they expire,
|
||||
preventing connection failures. Should be called after the initial
|
||||
Prisma client connection is established.
|
||||
"""
|
||||
if not self.iam_token_db_auth:
|
||||
verbose_proxy_logger.debug(
|
||||
"IAM token auth not enabled, skipping token refresh task"
|
||||
)
|
||||
return
|
||||
|
||||
if self._token_refresh_task is not None:
|
||||
verbose_proxy_logger.debug("Token refresh task already running")
|
||||
return
|
||||
|
||||
self._token_refresh_task = asyncio.create_task(self._token_refresh_loop())
|
||||
verbose_proxy_logger.info(
|
||||
"Started RDS IAM token proactive refresh background task"
|
||||
)
|
||||
|
||||
async def stop_token_refresh_task(self) -> None:
|
||||
"""
|
||||
Stop the background token refresh task gracefully.
|
||||
|
||||
Should be called during application shutdown to clean up resources.
|
||||
"""
|
||||
if self._token_refresh_task is None:
|
||||
return
|
||||
|
||||
self._token_refresh_task.cancel()
|
||||
try:
|
||||
await self._token_refresh_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._token_refresh_task = None
|
||||
verbose_proxy_logger.info("Stopped RDS IAM token refresh background task")
|
||||
|
||||
async def _token_refresh_loop(self) -> None:
|
||||
"""
|
||||
Background loop that proactively refreshes RDS IAM tokens before expiration.
|
||||
|
||||
Uses precise timing: calculates the exact sleep duration until the token
|
||||
needs to be refreshed (expiration - 3 minute buffer), then refreshes.
|
||||
This is more efficient than polling, requiring only 1 wake-up per token cycle.
|
||||
"""
|
||||
verbose_proxy_logger.info(
|
||||
f"RDS IAM token refresh loop started. "
|
||||
f"Tokens will be refreshed {self.TOKEN_REFRESH_BUFFER_SECONDS}s before expiration."
|
||||
)
|
||||
|
||||
while True:
|
||||
try:
|
||||
# Calculate exactly how long to sleep until next refresh
|
||||
sleep_seconds = self._calculate_seconds_until_refresh()
|
||||
|
||||
if sleep_seconds > 0:
|
||||
verbose_proxy_logger.info(
|
||||
f"RDS IAM token refresh scheduled in {sleep_seconds:.0f} seconds "
|
||||
f"({sleep_seconds / 60:.1f} minutes)"
|
||||
)
|
||||
await asyncio.sleep(sleep_seconds)
|
||||
|
||||
# Refresh the token
|
||||
verbose_proxy_logger.info("Proactively refreshing RDS IAM token...")
|
||||
await self._safe_refresh_token()
|
||||
|
||||
except asyncio.CancelledError:
|
||||
verbose_proxy_logger.info("RDS IAM token refresh loop cancelled")
|
||||
break
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.error(
|
||||
f"Error in RDS IAM token refresh loop: {e}. "
|
||||
f"Retrying in {self.FALLBACK_REFRESH_INTERVAL_SECONDS}s..."
|
||||
)
|
||||
# On error, wait before retrying to avoid tight error loops
|
||||
try:
|
||||
await asyncio.sleep(self.FALLBACK_REFRESH_INTERVAL_SECONDS)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
|
||||
async def _safe_refresh_token(self) -> None:
|
||||
"""
|
||||
Refresh the RDS IAM token with proper locking to prevent race conditions.
|
||||
|
||||
Uses an asyncio lock to ensure only one refresh operation happens at a time,
|
||||
preventing multiple concurrent reconnection attempts.
|
||||
"""
|
||||
async with self._reconnection_lock:
|
||||
new_db_url = self.get_rds_iam_token()
|
||||
if new_db_url:
|
||||
await self.recreate_prisma_client(new_db_url)
|
||||
self._last_refresh_time = datetime.utcnow()
|
||||
verbose_proxy_logger.info(
|
||||
"RDS IAM token refreshed successfully. New token valid for ~15 minutes."
|
||||
)
|
||||
else:
|
||||
verbose_proxy_logger.error(
|
||||
"Failed to generate new RDS IAM token during proactive refresh"
|
||||
)
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
"""
|
||||
Proxy attribute access to the underlying Prisma client.
|
||||
|
||||
If IAM token auth is enabled and the token is expired, this method
|
||||
provides a synchronous fallback to refresh the token. However, this
|
||||
should rarely be needed since the background task proactively refreshes
|
||||
tokens before they expire.
|
||||
|
||||
FIXED: Now properly waits for reconnection to complete before returning,
|
||||
instead of the previous fire-and-forget pattern that caused the bug.
|
||||
"""
|
||||
original_attr = getattr(self._original_prisma, name)
|
||||
|
||||
if self.iam_token_db_auth:
|
||||
db_url = os.getenv("DATABASE_URL")
|
||||
if self.is_token_expired(db_url):
|
||||
db_url = self.get_rds_iam_token()
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
if db_url:
|
||||
# Check if token is expired (should be rare if background task is running)
|
||||
if self.is_token_expired(db_url):
|
||||
verbose_proxy_logger.warning(
|
||||
"RDS IAM token expired in __getattr__ - proactive refresh may have failed. "
|
||||
"Triggering synchronous fallback refresh..."
|
||||
)
|
||||
|
||||
new_db_url = self.get_rds_iam_token()
|
||||
if new_db_url:
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
if loop.is_running():
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self.recreate_prisma_client(db_url), loop
|
||||
# FIXED: Actually wait for the reconnection to complete!
|
||||
# The previous code used fire-and-forget which caused the bug.
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self.recreate_prisma_client(new_db_url), loop
|
||||
)
|
||||
try:
|
||||
# Wait up to 30 seconds for reconnection
|
||||
future.result(timeout=30)
|
||||
verbose_proxy_logger.info(
|
||||
"Synchronous token refresh completed successfully"
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.error(
|
||||
f"Failed to refresh token synchronously: {e}"
|
||||
)
|
||||
raise
|
||||
else:
|
||||
asyncio.run(self.recreate_prisma_client(db_url))
|
||||
asyncio.run(self.recreate_prisma_client(new_db_url))
|
||||
|
||||
# Get the NEW attribute after reconnection
|
||||
original_attr = getattr(self._original_prisma, name)
|
||||
else:
|
||||
raise ValueError("Failed to get RDS IAM token")
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
# +-------------------------------------------------------------+
|
||||
# Qualifire - Evaluate LLM outputs for quality, safety, and reliability
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Dict, List, Literal, Optional, Type
|
||||
|
||||
|
|
@ -15,12 +16,17 @@ from litellm.integrations.custom_guardrail import CustomGuardrail
|
|||
from litellm.litellm_core_utils.litellm_logging import (
|
||||
Logging as LiteLLMLoggingObj,
|
||||
)
|
||||
from litellm.llms.custom_httpx.http_handler import (
|
||||
get_async_httpx_client,
|
||||
httpxSpecialProvider,
|
||||
)
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.llms.openai import AllMessageValues
|
||||
from litellm.types.proxy.guardrails.guardrail_hooks.base import GuardrailConfigModel
|
||||
from litellm.types.utils import GenericGuardrailAPIInputs
|
||||
|
||||
GUARDRAIL_NAME = "qualifire"
|
||||
DEFAULT_QUALIFIRE_API_BASE = "https://proxy.qualifire.ai"
|
||||
|
||||
|
||||
class QualifireGuardrail(CustomGuardrail):
|
||||
|
|
@ -44,7 +50,7 @@ class QualifireGuardrail(CustomGuardrail):
|
|||
|
||||
Args:
|
||||
api_key: API key for Qualifire (or use QUALIFIRE_API_KEY env var)
|
||||
api_base: Optional custom API base URL
|
||||
api_base: Optional custom API base URL (defaults to https://api.qualifire.ai)
|
||||
evaluation_id: Pre-configured evaluation ID from Qualifire dashboard
|
||||
prompt_injections: Enable prompt injection detection (default if no other checks)
|
||||
hallucinations_check: Enable hallucination detection
|
||||
|
|
@ -64,6 +70,7 @@ class QualifireGuardrail(CustomGuardrail):
|
|||
api_base
|
||||
or get_secret_str("QUALIFIRE_BASE_URL")
|
||||
or os.environ.get("QUALIFIRE_BASE_URL")
|
||||
or DEFAULT_QUALIFIRE_API_BASE
|
||||
)
|
||||
self.evaluation_id = evaluation_id
|
||||
self.prompt_injections = prompt_injections
|
||||
|
|
@ -79,7 +86,11 @@ class QualifireGuardrail(CustomGuardrail):
|
|||
if not self._has_any_check_enabled() and not self.evaluation_id:
|
||||
self.prompt_injections = True
|
||||
|
||||
self._client = None
|
||||
# Initialize async HTTP client for direct API calls
|
||||
self.async_handler = get_async_httpx_client(
|
||||
llm_provider=httpxSpecialProvider.GuardrailCallback
|
||||
)
|
||||
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def _has_any_check_enabled(self) -> bool:
|
||||
|
|
@ -96,43 +107,22 @@ class QualifireGuardrail(CustomGuardrail):
|
|||
]
|
||||
)
|
||||
|
||||
def _get_client(self):
|
||||
"""Lazy initialization of Qualifire client."""
|
||||
if self._client is None:
|
||||
try:
|
||||
from qualifire.client import Client
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"qualifire package is required for QualifireGuardrail. "
|
||||
"Install it with: pip install qualifire"
|
||||
)
|
||||
|
||||
client_kwargs: Dict[str, Any] = {}
|
||||
if self.qualifire_api_key:
|
||||
client_kwargs["api_key"] = self.qualifire_api_key
|
||||
if self.qualifire_api_base:
|
||||
client_kwargs["base_url"] = self.qualifire_api_base
|
||||
|
||||
self._client = Client(**client_kwargs)
|
||||
|
||||
return self._client
|
||||
|
||||
def _convert_messages_to_qualifire_format(
|
||||
def _convert_messages_to_api_format(
|
||||
self, messages: List[AllMessageValues]
|
||||
) -> List[Any]:
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Convert LiteLLM messages to Qualifire's LLMMessage format.
|
||||
Convert LiteLLM messages to Qualifire API format.
|
||||
Supports tool calls for tool_selection_quality_check.
|
||||
"""
|
||||
try:
|
||||
from qualifire.types import LLMMessage, LLMToolCall
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"qualifire package is required for QualifireGuardrail. "
|
||||
"Install it with: pip install qualifire"
|
||||
)
|
||||
|
||||
qualifire_messages = []
|
||||
Returns a list of dicts matching the API's ModelInvocationCanonicalMessage schema:
|
||||
{
|
||||
"role": "user" | "assistant" | "system" | "tool",
|
||||
"content": "...",
|
||||
"tool_call_id": "...", # optional
|
||||
"tool_calls": [{"id": "...", "name": "...", "arguments": {...}}] # optional
|
||||
}
|
||||
"""
|
||||
api_messages = []
|
||||
for msg in messages:
|
||||
role = msg.get("role", "user")
|
||||
content = msg.get("content", "")
|
||||
|
|
@ -147,42 +137,86 @@ class QualifireGuardrail(CustomGuardrail):
|
|||
text_parts.append(part)
|
||||
content = "\n".join(text_parts)
|
||||
|
||||
llm_message_kwargs: Dict[str, Any] = {
|
||||
api_message: Dict[str, Any] = {
|
||||
"role": role,
|
||||
"content": content if isinstance(content, str) else str(content),
|
||||
}
|
||||
|
||||
# Handle tool_call_id for tool response messages
|
||||
tool_call_id = msg.get("tool_call_id")
|
||||
if tool_call_id:
|
||||
api_message["tool_call_id"] = tool_call_id
|
||||
|
||||
# Handle tool calls if present
|
||||
tool_calls = msg.get("tool_calls")
|
||||
if tool_calls and isinstance(tool_calls, list):
|
||||
qualifire_tool_calls = []
|
||||
api_tool_calls = []
|
||||
for tc in tool_calls:
|
||||
if isinstance(tc, dict):
|
||||
function_info = tc.get("function", {})
|
||||
# Arguments can be a string (JSON) or dict
|
||||
args = function_info.get("arguments", {})
|
||||
if isinstance(args, str):
|
||||
import json
|
||||
|
||||
try:
|
||||
args = json.loads(args)
|
||||
except json.JSONDecodeError:
|
||||
args = {}
|
||||
qualifire_tool_calls.append(
|
||||
LLMToolCall(
|
||||
id=tc.get("id") or "",
|
||||
name=function_info.get("name") or "",
|
||||
arguments=args if isinstance(args, dict) else {},
|
||||
)
|
||||
api_tool_calls.append(
|
||||
{
|
||||
"id": tc.get("id") or "",
|
||||
"name": function_info.get("name") or "",
|
||||
"arguments": args if isinstance(args, dict) else {},
|
||||
}
|
||||
)
|
||||
if qualifire_tool_calls:
|
||||
llm_message_kwargs["tool_calls"] = qualifire_tool_calls
|
||||
if api_tool_calls:
|
||||
api_message["tool_calls"] = api_tool_calls
|
||||
|
||||
qualifire_messages.append(LLMMessage(**llm_message_kwargs))
|
||||
api_messages.append(api_message)
|
||||
|
||||
return qualifire_messages
|
||||
return api_messages
|
||||
|
||||
def _check_if_flagged(self, result: Any) -> bool:
|
||||
def _convert_tools_to_api_format(
|
||||
self, tools: Optional[List[Any]]
|
||||
) -> Optional[List[Dict[str, Any]]]:
|
||||
"""
|
||||
Convert OpenAI-format tools to Qualifire API format.
|
||||
|
||||
Returns a list of dicts matching the API's ModelInvocationToolDefinition schema:
|
||||
{
|
||||
"name": "...",
|
||||
"description": "...",
|
||||
"parameters": {...}
|
||||
}
|
||||
"""
|
||||
if not tools:
|
||||
return None
|
||||
|
||||
api_tools = []
|
||||
for tool in tools:
|
||||
if isinstance(tool, dict):
|
||||
# Handle OpenAI function tool format
|
||||
if tool.get("type") == "function":
|
||||
function_def = tool.get("function", {})
|
||||
api_tools.append(
|
||||
{
|
||||
"name": function_def.get("name", ""),
|
||||
"description": function_def.get("description", ""),
|
||||
"parameters": function_def.get("parameters", {}),
|
||||
}
|
||||
)
|
||||
# Handle direct tool format
|
||||
elif "name" in tool:
|
||||
api_tools.append(
|
||||
{
|
||||
"name": tool.get("name", ""),
|
||||
"description": tool.get("description", ""),
|
||||
"parameters": tool.get("parameters", {}),
|
||||
}
|
||||
)
|
||||
|
||||
return api_tools if api_tools else None
|
||||
|
||||
def _check_if_flagged(self, result: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Check if the Qualifire evaluation result indicates flagged content.
|
||||
|
||||
|
|
@ -190,65 +224,53 @@ class QualifireGuardrail(CustomGuardrail):
|
|||
A high score (close to 100) indicates GOOD content, low score indicates problems.
|
||||
"""
|
||||
# Check evaluation results for any flagged items
|
||||
evaluation_results = getattr(result, "evaluationResults", None) or []
|
||||
if isinstance(result, dict):
|
||||
evaluation_results = result.get("evaluationResults", []) or []
|
||||
evaluation_results = result.get("evaluationResults", []) or []
|
||||
|
||||
for eval_result in evaluation_results:
|
||||
results: List[Any] = []
|
||||
if isinstance(eval_result, dict):
|
||||
results = eval_result.get("results", []) or []
|
||||
else:
|
||||
results = getattr(eval_result, "results", []) or []
|
||||
|
||||
results = eval_result.get("results", []) or []
|
||||
for r in results:
|
||||
flagged = (
|
||||
r.get("flagged")
|
||||
if isinstance(r, dict)
|
||||
else getattr(r, "flagged", False)
|
||||
)
|
||||
if flagged:
|
||||
if r.get("flagged"):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _build_evaluate_kwargs(
|
||||
def _build_evaluate_payload(
|
||||
self,
|
||||
qualifire_messages: List[Any],
|
||||
api_messages: List[Dict[str, Any]],
|
||||
output: Optional[str],
|
||||
assertions: Optional[List[str]],
|
||||
available_tools: Optional[List[Any]],
|
||||
available_tools: Optional[List[Dict[str, Any]]],
|
||||
) -> Dict[str, Any]:
|
||||
"""Build kwargs dictionary for the evaluate call."""
|
||||
kwargs: Dict[str, Any] = {"messages": qualifire_messages}
|
||||
"""Build payload dictionary for the /api/evaluation/evaluate endpoint."""
|
||||
payload: Dict[str, Any] = {"messages": api_messages}
|
||||
|
||||
if output is not None:
|
||||
kwargs["output"] = output
|
||||
payload["output"] = output
|
||||
|
||||
# Add enabled checks
|
||||
if self.prompt_injections:
|
||||
kwargs["prompt_injections"] = True
|
||||
payload["prompt_injections"] = True
|
||||
if self.hallucinations_check:
|
||||
kwargs["hallucinations_check"] = True
|
||||
payload["hallucinations_check"] = True
|
||||
if self.grounding_check:
|
||||
kwargs["grounding_check"] = True
|
||||
payload["grounding_check"] = True
|
||||
if self.pii_check:
|
||||
kwargs["pii_check"] = True
|
||||
payload["pii_check"] = True
|
||||
if self.content_moderation_check:
|
||||
kwargs["content_moderation_check"] = True
|
||||
payload["content_moderation_check"] = True
|
||||
if self.tool_selection_quality_check:
|
||||
# Only enable tool_selection_quality_check if available_tools is provided
|
||||
if available_tools:
|
||||
kwargs["tool_selection_quality_check"] = True
|
||||
kwargs["available_tools"] = available_tools
|
||||
payload["tool_selection_quality_check"] = True
|
||||
payload["available_tools"] = available_tools
|
||||
else:
|
||||
verbose_proxy_logger.debug(
|
||||
"Qualifire Guardrail: tool_selection_quality_check enabled but no available_tools provided, skipping this check"
|
||||
)
|
||||
if assertions:
|
||||
kwargs["assertions"] = assertions
|
||||
payload["assertions"] = assertions
|
||||
|
||||
return kwargs
|
||||
return payload
|
||||
|
||||
async def _run_qualifire_check(
|
||||
self,
|
||||
|
|
@ -274,11 +296,17 @@ class QualifireGuardrail(CustomGuardrail):
|
|||
assertions = dynamic_params.get("assertions") or self.assertions
|
||||
on_flagged = dynamic_params.get("on_flagged") or self.on_flagged
|
||||
|
||||
try:
|
||||
client = self._get_client()
|
||||
qualifire_messages = self._convert_messages_to_qualifire_format(messages)
|
||||
# Prepare headers
|
||||
headers = {
|
||||
"X-Qualifire-API-Key": self.qualifire_api_key or "",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# Use invoke_evaluation if evaluation_id is provided
|
||||
try:
|
||||
# Convert messages to API format
|
||||
api_messages = self._convert_messages_to_api_format(messages)
|
||||
|
||||
# Use invoke endpoint if evaluation_id is provided
|
||||
if evaluation_id:
|
||||
# For invoke_evaluation, we need to extract input/output
|
||||
input_text = ""
|
||||
|
|
@ -291,25 +319,47 @@ class QualifireGuardrail(CustomGuardrail):
|
|||
input_text = content
|
||||
break
|
||||
|
||||
result = client.invoke_evaluation(
|
||||
evaluation_id=evaluation_id,
|
||||
input=input_text,
|
||||
output=output or "",
|
||||
)
|
||||
payload = {
|
||||
"evaluation_id": evaluation_id,
|
||||
"input": input_text,
|
||||
"output": output or "",
|
||||
"messages": api_messages,
|
||||
}
|
||||
|
||||
# Convert tools if provided
|
||||
api_tools = self._convert_tools_to_api_format(available_tools)
|
||||
if api_tools:
|
||||
payload["available_tools"] = api_tools
|
||||
|
||||
url = f"{self.qualifire_api_base}/api/evaluation/invoke"
|
||||
else:
|
||||
# Use evaluate with individual checks
|
||||
kwargs = self._build_evaluate_kwargs(
|
||||
qualifire_messages=qualifire_messages,
|
||||
# Use evaluate endpoint with individual checks
|
||||
api_tools = self._convert_tools_to_api_format(available_tools)
|
||||
payload = self._build_evaluate_payload(
|
||||
api_messages=api_messages,
|
||||
output=output,
|
||||
assertions=assertions,
|
||||
available_tools=available_tools,
|
||||
available_tools=api_tools,
|
||||
)
|
||||
result = client.evaluate(**kwargs)
|
||||
url = f"{self.qualifire_api_base}/api/evaluation/evaluate"
|
||||
|
||||
# Convert result to dict for logging
|
||||
verbose_proxy_logger.debug(
|
||||
f"Qualifire Guardrail: Making request to {url}"
|
||||
)
|
||||
|
||||
# Make the API request
|
||||
response = await self.async_handler.post(
|
||||
url=url,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
# Extract response info for logging
|
||||
qualifire_response = {
|
||||
"score": getattr(result, "score", None),
|
||||
"status": getattr(result, "status", None),
|
||||
"score": result.get("score"),
|
||||
"status": result.get("status"),
|
||||
}
|
||||
|
||||
verbose_proxy_logger.debug(
|
||||
|
|
|
|||
|
|
@ -161,7 +161,6 @@ class KeyAndTeamLoggingSettings:
|
|||
|
||||
@staticmethod
|
||||
def get_team_dynamic_logging_settings(user_api_key_dict: UserAPIKeyAuth):
|
||||
|
||||
if (
|
||||
user_api_key_dict.team_metadata is not None
|
||||
and "logging" in user_api_key_dict.team_metadata
|
||||
|
|
@ -174,12 +173,12 @@ def _get_dynamic_logging_metadata(
|
|||
user_api_key_dict: UserAPIKeyAuth, proxy_config: ProxyConfig
|
||||
) -> Optional[TeamCallbackMetadata]:
|
||||
callback_settings_obj: Optional[TeamCallbackMetadata] = None
|
||||
key_dynamic_logging_settings: Optional[dict] = (
|
||||
KeyAndTeamLoggingSettings.get_key_dynamic_logging_settings(user_api_key_dict)
|
||||
)
|
||||
team_dynamic_logging_settings: Optional[dict] = (
|
||||
KeyAndTeamLoggingSettings.get_team_dynamic_logging_settings(user_api_key_dict)
|
||||
)
|
||||
key_dynamic_logging_settings: Optional[
|
||||
dict
|
||||
] = KeyAndTeamLoggingSettings.get_key_dynamic_logging_settings(user_api_key_dict)
|
||||
team_dynamic_logging_settings: Optional[
|
||||
dict
|
||||
] = KeyAndTeamLoggingSettings.get_team_dynamic_logging_settings(user_api_key_dict)
|
||||
#########################################################################################
|
||||
# Key-based callbacks
|
||||
#########################################################################################
|
||||
|
|
@ -462,7 +461,6 @@ class LiteLLMProxyRequestSetup:
|
|||
team_id=user_api_key_dict.team_id,
|
||||
) # handles aliases, wildcards, etc.
|
||||
):
|
||||
|
||||
_headers = LiteLLMProxyRequestSetup.add_headers_to_llm_call(
|
||||
headers, user_api_key_dict
|
||||
)
|
||||
|
|
@ -663,11 +661,11 @@ class LiteLLMProxyRequestSetup:
|
|||
|
||||
## KEY-LEVEL SPEND LOGS / TAGS
|
||||
if "tags" in key_metadata and key_metadata["tags"] is not None:
|
||||
data[_metadata_variable_name]["tags"] = (
|
||||
LiteLLMProxyRequestSetup._merge_tags(
|
||||
request_tags=data[_metadata_variable_name].get("tags"),
|
||||
tags_to_add=key_metadata["tags"],
|
||||
)
|
||||
data[_metadata_variable_name][
|
||||
"tags"
|
||||
] = LiteLLMProxyRequestSetup._merge_tags(
|
||||
request_tags=data[_metadata_variable_name].get("tags"),
|
||||
tags_to_add=key_metadata["tags"],
|
||||
)
|
||||
if "disable_global_guardrails" in key_metadata and isinstance(
|
||||
key_metadata["disable_global_guardrails"], bool
|
||||
|
|
@ -815,11 +813,14 @@ async def add_litellm_data_to_request( # noqa: PLR0915
|
|||
# Init - Proxy Server Request
|
||||
# we do this as soon as entering so we track the original request
|
||||
##########################################################
|
||||
# Track arrival time for queue time metric
|
||||
arrival_time = time.time()
|
||||
data["proxy_server_request"] = {
|
||||
"url": str(request.url),
|
||||
"method": request.method,
|
||||
"headers": _headers,
|
||||
"body": copy.copy(data), # use copy instead of deepcopy
|
||||
"arrival_time": arrival_time, # Track when request arrived at proxy
|
||||
}
|
||||
|
||||
safe_add_api_version_from_query_params(data, request)
|
||||
|
|
@ -930,9 +931,9 @@ async def add_litellm_data_to_request( # noqa: PLR0915
|
|||
data[_metadata_variable_name]["litellm_api_version"] = version
|
||||
|
||||
if general_settings is not None:
|
||||
data[_metadata_variable_name]["global_max_parallel_requests"] = (
|
||||
general_settings.get("global_max_parallel_requests", None)
|
||||
)
|
||||
data[_metadata_variable_name][
|
||||
"global_max_parallel_requests"
|
||||
] = general_settings.get("global_max_parallel_requests", None)
|
||||
|
||||
### KEY-LEVEL Controls
|
||||
key_metadata = user_api_key_dict.metadata
|
||||
|
|
|
|||
|
|
@ -36,12 +36,19 @@ from litellm._logging import verbose_logger, verbose_proxy_logger
|
|||
from litellm._uuid import uuid
|
||||
from litellm.constants import LITELLM_PROXY_ADMIN_NAME
|
||||
from litellm.proxy._experimental.mcp_server.utils import (
|
||||
get_server_prefix,
|
||||
validate_and_normalize_mcp_server_payload,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/v1/mcp", tags=["mcp"])
|
||||
|
||||
MCP_AVAILABLE: bool = True
|
||||
|
||||
TEMPORARY_MCP_SERVER_TTL_SECONDS = 300
|
||||
DEFAULT_MCP_REGISTRY_VERSION = "1.0.0"
|
||||
LITELLM_MCP_SERVER_NAME = "litellm-mcp-server"
|
||||
LITELLM_MCP_SERVER_DESCRIPTION = "MCP Server for LiteLLM"
|
||||
|
||||
try:
|
||||
importlib.import_module("mcp")
|
||||
except ImportError as e:
|
||||
|
|
@ -57,6 +64,7 @@ if MCP_AVAILABLE:
|
|||
update_mcp_server,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
get_request_base_url,
|
||||
authorize_with_server,
|
||||
exchange_token_with_server,
|
||||
register_client_with_server,
|
||||
|
|
@ -89,6 +97,66 @@ if MCP_AVAILABLE:
|
|||
server: MCPServer
|
||||
expires_at: datetime
|
||||
|
||||
def _is_public_registry_enabled() -> bool:
|
||||
from litellm.proxy.proxy_server import (
|
||||
general_settings as proxy_general_settings,
|
||||
)
|
||||
|
||||
return bool(proxy_general_settings.get("enable_mcp_registry"))
|
||||
|
||||
def _build_registry_remote_url(base_url: str, path: str) -> str:
|
||||
normalized_base = base_url.rstrip("/")
|
||||
normalized_path = path if path.startswith("/") else f"/{path}"
|
||||
return f"{normalized_base}{normalized_path}"
|
||||
|
||||
def _build_mcp_registry_server_name(server: MCPServer) -> str:
|
||||
if server.alias:
|
||||
return server.alias
|
||||
if server.server_name:
|
||||
return server.server_name
|
||||
return server.server_id
|
||||
|
||||
def _build_mcp_registry_entry_for_server(
|
||||
server: MCPServer, base_url: str
|
||||
) -> Dict[str, Any]:
|
||||
server_name = _build_mcp_registry_server_name(server)
|
||||
title = server_name
|
||||
description = server_name
|
||||
version = DEFAULT_MCP_REGISTRY_VERSION
|
||||
|
||||
server_prefix = get_server_prefix(server)
|
||||
if not server_prefix:
|
||||
raise ValueError("MCP server prefix is missing")
|
||||
remote_url = _build_registry_remote_url(base_url, f"/{server_prefix}/mcp")
|
||||
|
||||
return {
|
||||
"name": server_name,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"version": version,
|
||||
"remotes": [
|
||||
{
|
||||
"type": "streamable-http",
|
||||
"url": remote_url,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
def _build_builtin_registry_entry(base_url: str) -> Dict[str, Any]:
|
||||
remote_url = _build_registry_remote_url(base_url, "/mcp")
|
||||
return {
|
||||
"name": LITELLM_MCP_SERVER_NAME,
|
||||
"title": LITELLM_MCP_SERVER_NAME,
|
||||
"description": LITELLM_MCP_SERVER_DESCRIPTION,
|
||||
"version": DEFAULT_MCP_REGISTRY_VERSION,
|
||||
"remotes": [
|
||||
{
|
||||
"type": "streamable-http",
|
||||
"url": remote_url,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
_temporary_mcp_servers: Dict[str, _TemporaryMCPServerEntry] = {}
|
||||
|
||||
def _prune_expired_temporary_mcp_servers() -> None:
|
||||
|
|
@ -302,15 +370,42 @@ if MCP_AVAILABLE:
|
|||
access_groups_list = sorted(list(access_groups))
|
||||
return {"access_groups": access_groups_list}
|
||||
|
||||
@router.get(
|
||||
"/registry.json",
|
||||
tags=["mcp"],
|
||||
description="MCP registry endpoint. Spec: https://github.com/modelcontextprotocol/registry",
|
||||
)
|
||||
async def get_mcp_registry(request: Request):
|
||||
if not _is_public_registry_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="MCP registry is not enabled",
|
||||
)
|
||||
|
||||
base_url = get_request_base_url(request)
|
||||
registry_servers: List[Dict[str, Any]] = []
|
||||
registry_servers.append({"server": _build_builtin_registry_entry(base_url)})
|
||||
|
||||
registered_servers = list(global_mcp_server_manager.get_registry().values())
|
||||
registered_servers.sort(key=_build_mcp_registry_server_name)
|
||||
|
||||
for server in registered_servers:
|
||||
try:
|
||||
entry = _build_mcp_registry_entry_for_server(server, base_url)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.debug(
|
||||
f"Skipping MCP server {getattr(server, 'server_id', 'unknown')} in registry: {e}"
|
||||
)
|
||||
continue
|
||||
registry_servers.append({"server": entry})
|
||||
|
||||
return {"servers": registry_servers}
|
||||
|
||||
## FastAPI Routes
|
||||
def _get_user_mcp_management_mode() -> UserMCPManagementMode:
|
||||
proxy_general_settings: dict = {}
|
||||
try:
|
||||
from litellm.proxy.proxy_server import (
|
||||
general_settings as proxy_general_settings,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
from litellm.proxy.proxy_server import (
|
||||
general_settings as proxy_general_settings,
|
||||
)
|
||||
|
||||
mode = proxy_general_settings.get("user_mcp_management_mode")
|
||||
if mode == "view_all":
|
||||
|
|
|
|||
|
|
@ -229,7 +229,7 @@ from litellm.proxy.batches_endpoints.endpoints import router as batches_router
|
|||
from litellm.proxy.caching_routes import router as caching_router
|
||||
from litellm.proxy.common_request_processing import (
|
||||
ProxyBaseLLMRequestProcessing,
|
||||
create_streaming_response,
|
||||
create_response,
|
||||
)
|
||||
from litellm.proxy.common_utils.callback_utils import initialize_callbacks_on_proxy
|
||||
from litellm.proxy.common_utils.debug_utils import init_verbose_loggers
|
||||
|
|
@ -533,9 +533,9 @@ except ImportError:
|
|||
server_root_path = os.getenv("SERVER_ROOT_PATH", "")
|
||||
_license_check = LicenseCheck()
|
||||
premium_user: bool = _license_check.is_premium()
|
||||
premium_user_data: Optional["EnterpriseLicenseData"] = (
|
||||
_license_check.airgapped_license_data
|
||||
)
|
||||
premium_user_data: Optional[
|
||||
"EnterpriseLicenseData"
|
||||
] = _license_check.airgapped_license_data
|
||||
global_max_parallel_request_retries_env: Optional[str] = os.getenv(
|
||||
"LITELLM_GLOBAL_MAX_PARALLEL_REQUEST_RETRIES"
|
||||
)
|
||||
|
|
@ -658,7 +658,7 @@ async def _initialize_shared_aiohttp_session():
|
|||
|
||||
|
||||
@asynccontextmanager
|
||||
async def proxy_startup_event(app: FastAPI):
|
||||
async def proxy_startup_event(app: FastAPI): # noqa: PLR0915
|
||||
global prisma_client, master_key, use_background_health_checks, llm_router, llm_model_list, general_settings, proxy_budget_rescheduler_min_time, proxy_budget_rescheduler_max_time, litellm_proxy_admin_name, db_writer_client, store_model_in_db, premium_user, _license_check, proxy_batch_polling_interval, shared_aiohttp_session
|
||||
import json
|
||||
|
||||
|
|
@ -788,6 +788,17 @@ async def proxy_startup_event(app: FastAPI):
|
|||
except Exception as e:
|
||||
verbose_proxy_logger.error(f"Error closing shared aiohttp session: {e}")
|
||||
|
||||
# Shutdown event - stop RDS IAM token refresh background task
|
||||
if (
|
||||
prisma_client is not None
|
||||
and hasattr(prisma_client, "db")
|
||||
and hasattr(prisma_client.db, "stop_token_refresh_task")
|
||||
):
|
||||
try:
|
||||
await prisma_client.db.stop_token_refresh_task()
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.error(f"Error stopping token refresh task: {e}")
|
||||
|
||||
await proxy_shutdown_event() # type: ignore[reportGeneralTypeIssues]
|
||||
|
||||
|
||||
|
|
@ -1083,9 +1094,7 @@ try:
|
|||
# In non-root Docker, we restructure in /var/lib/litellm/ui.
|
||||
try:
|
||||
_restructure_ui_html_files(ui_path)
|
||||
verbose_proxy_logger.info(
|
||||
f"Restructured UI directory: {ui_path}"
|
||||
)
|
||||
verbose_proxy_logger.info(f"Restructured UI directory: {ui_path}")
|
||||
except PermissionError as e:
|
||||
verbose_proxy_logger.exception(
|
||||
f"Permission error while restructuring UI directory {ui_path}: {e}"
|
||||
|
|
@ -1171,9 +1180,9 @@ master_key: Optional[str] = None
|
|||
config_agents: Optional[List[AgentConfig]] = None
|
||||
otel_logging = False
|
||||
prisma_client: Optional[PrismaClient] = None
|
||||
shared_aiohttp_session: Optional["ClientSession"] = (
|
||||
None # Global shared session for connection reuse
|
||||
)
|
||||
shared_aiohttp_session: Optional[
|
||||
"ClientSession"
|
||||
] = None # Global shared session for connection reuse
|
||||
user_api_key_cache = DualCache(
|
||||
default_in_memory_ttl=UserAPIKeyCacheTTLEnum.in_memory_cache_ttl.value
|
||||
)
|
||||
|
|
@ -1181,9 +1190,9 @@ model_max_budget_limiter = _PROXY_VirtualKeyModelMaxBudgetLimiter(
|
|||
dual_cache=user_api_key_cache
|
||||
)
|
||||
litellm.logging_callback_manager.add_litellm_callback(model_max_budget_limiter)
|
||||
redis_usage_cache: Optional[RedisCache] = (
|
||||
None # redis cache used for tracking spend, tpm/rpm limits
|
||||
)
|
||||
redis_usage_cache: Optional[
|
||||
RedisCache
|
||||
] = None # redis cache used for tracking spend, tpm/rpm limits
|
||||
polling_via_cache_enabled: Union[Literal["all"], List[str], bool] = False
|
||||
polling_cache_ttl: int = 3600 # Default 1 hour TTL for polling cache
|
||||
user_custom_auth = None
|
||||
|
|
@ -1522,9 +1531,9 @@ async def update_cache( # noqa: PLR0915
|
|||
_id = "team_id:{}".format(team_id)
|
||||
try:
|
||||
# Fetch the existing cost for the given user
|
||||
existing_spend_obj: Optional[LiteLLM_TeamTable] = (
|
||||
await user_api_key_cache.async_get_cache(key=_id)
|
||||
)
|
||||
existing_spend_obj: Optional[
|
||||
LiteLLM_TeamTable
|
||||
] = await user_api_key_cache.async_get_cache(key=_id)
|
||||
if existing_spend_obj is None:
|
||||
# do nothing if team not in api key cache
|
||||
return
|
||||
|
|
@ -1876,7 +1885,6 @@ class ProxyConfig:
|
|||
"environment_variables" in config_to_save
|
||||
and config_to_save["environment_variables"]
|
||||
):
|
||||
|
||||
# decrypt the environment_variables - in case a caller function has already encrypted the environment_variables
|
||||
decrypted_env_vars = self._decrypt_and_set_db_env_variables(
|
||||
environment_variables=config_to_save["environment_variables"],
|
||||
|
|
@ -2794,21 +2802,21 @@ class ProxyConfig:
|
|||
verbose_proxy_logger.debug(f"_alerting_callbacks: {general_settings}")
|
||||
if _alerting_callbacks is None:
|
||||
return
|
||||
|
||||
|
||||
# Ensure proxy_logging_obj.alerting is set for all alerting types
|
||||
_alerting_value = general_settings.get("alerting", None)
|
||||
verbose_proxy_logger.debug(f"_load_alerting_settings: Calling update_values with alerting={_alerting_value}")
|
||||
verbose_proxy_logger.debug(
|
||||
f"_load_alerting_settings: Calling update_values with alerting={_alerting_value}"
|
||||
)
|
||||
proxy_logging_obj.update_values(
|
||||
alerting=_alerting_value,
|
||||
alerting_threshold=general_settings.get("alerting_threshold", 600),
|
||||
alert_types=general_settings.get("alert_types", None),
|
||||
alert_to_webhook_url=general_settings.get(
|
||||
"alert_to_webhook_url", None
|
||||
),
|
||||
alert_to_webhook_url=general_settings.get("alert_to_webhook_url", None),
|
||||
alerting_args=general_settings.get("alerting_args", None),
|
||||
redis_cache=redis_usage_cache,
|
||||
)
|
||||
|
||||
|
||||
for _alert in _alerting_callbacks:
|
||||
if _alert == "slack":
|
||||
# [OLD] v0 implementation - already handled by update_values above
|
||||
|
|
@ -3222,6 +3230,84 @@ class ProxyConfig:
|
|||
decrypted_variables[k] = decrypted_value
|
||||
return decrypted_variables
|
||||
|
||||
async def _get_hierarchical_router_settings(
|
||||
self,
|
||||
user_api_key_dict: Optional["UserAPIKeyAuth"],
|
||||
prisma_client: Optional[PrismaClient],
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Get router_settings in priority order: Key > Team > Global
|
||||
|
||||
Returns:
|
||||
dict: Combined router_settings, or None if no settings found
|
||||
"""
|
||||
if prisma_client is None:
|
||||
return None
|
||||
|
||||
import json
|
||||
import yaml
|
||||
|
||||
# 1. Try key-level router_settings
|
||||
if user_api_key_dict is not None:
|
||||
# Check if router_settings is available on the key object
|
||||
key_router_settings_value = getattr(user_api_key_dict, "router_settings", None)
|
||||
if key_router_settings_value is not None:
|
||||
key_router_settings = None
|
||||
if isinstance(key_router_settings_value, str):
|
||||
try:
|
||||
key_router_settings = yaml.safe_load(key_router_settings_value)
|
||||
except (yaml.YAMLError, json.JSONDecodeError):
|
||||
try:
|
||||
key_router_settings = json.loads(key_router_settings_value)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
elif isinstance(key_router_settings_value, dict):
|
||||
key_router_settings = key_router_settings_value
|
||||
|
||||
# If key has router_settings (non-empty dict), use it
|
||||
if key_router_settings is not None and isinstance(key_router_settings, dict) and key_router_settings:
|
||||
return key_router_settings
|
||||
|
||||
# 2. Try team-level router_settings
|
||||
if user_api_key_dict is not None and user_api_key_dict.team_id is not None:
|
||||
try:
|
||||
team_obj = await prisma_client.db.litellm_teamtable.find_unique(
|
||||
where={"team_id": user_api_key_dict.team_id}
|
||||
)
|
||||
if team_obj is not None:
|
||||
team_router_settings_value = getattr(team_obj, "router_settings", None)
|
||||
if team_router_settings_value is not None:
|
||||
team_router_settings = None
|
||||
if isinstance(team_router_settings_value, str):
|
||||
try:
|
||||
team_router_settings = yaml.safe_load(team_router_settings_value)
|
||||
except (yaml.YAMLError, json.JSONDecodeError):
|
||||
try:
|
||||
team_router_settings = json.loads(team_router_settings_value)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
elif isinstance(team_router_settings_value, dict):
|
||||
team_router_settings = team_router_settings_value
|
||||
|
||||
# If team has router_settings (non-empty dict), use it
|
||||
if team_router_settings is not None and isinstance(team_router_settings, dict) and team_router_settings:
|
||||
return team_router_settings
|
||||
except Exception:
|
||||
# If team lookup fails, continue to global settings
|
||||
pass
|
||||
|
||||
# 3. Try global router_settings
|
||||
try:
|
||||
db_router_settings = await prisma_client.db.litellm_config.find_first(
|
||||
where={"param_name": "router_settings"}
|
||||
)
|
||||
if db_router_settings is not None and isinstance(db_router_settings.param_value, dict) and db_router_settings.param_value:
|
||||
return db_router_settings.param_value
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
async def _add_router_settings_from_db_config(
|
||||
self,
|
||||
config_data: dict,
|
||||
|
|
@ -3279,7 +3365,7 @@ class ProxyConfig:
|
|||
proxy_logging_obj: ProxyLogging
|
||||
"""
|
||||
_general_settings = config_data.get("general_settings", {})
|
||||
|
||||
|
||||
if _general_settings is not None and "alerting" in _general_settings:
|
||||
if (
|
||||
general_settings is not None
|
||||
|
|
@ -3294,7 +3380,8 @@ class ProxyConfig:
|
|||
_merged_alerting = list(_yaml_alerting.union(_db_alerting))
|
||||
# Preserve order: YAML values first, then DB values
|
||||
_merged_alerting = list(general_settings["alerting"]) + [
|
||||
item for item in _general_settings["alerting"]
|
||||
item
|
||||
for item in _general_settings["alerting"]
|
||||
if item not in general_settings["alerting"]
|
||||
]
|
||||
verbose_proxy_logger.debug(
|
||||
|
|
@ -3608,7 +3695,6 @@ class ProxyConfig:
|
|||
await self._init_vector_stores_in_db(prisma_client=prisma_client)
|
||||
|
||||
if self._should_load_db_object(object_type="vector_store_indexes"):
|
||||
|
||||
await self._init_vector_store_indexes_in_db(prisma_client=prisma_client)
|
||||
|
||||
if self._should_load_db_object(object_type="mcp"):
|
||||
|
|
@ -3807,10 +3893,10 @@ class ProxyConfig:
|
|||
)
|
||||
|
||||
try:
|
||||
guardrails_in_db: List[Guardrail] = (
|
||||
await GuardrailRegistry.get_all_guardrails_from_db(
|
||||
prisma_client=prisma_client
|
||||
)
|
||||
guardrails_in_db: List[
|
||||
Guardrail
|
||||
] = await GuardrailRegistry.get_all_guardrails_from_db(
|
||||
prisma_client=prisma_client
|
||||
)
|
||||
verbose_proxy_logger.debug(
|
||||
"guardrails from the DB %s", str(guardrails_in_db)
|
||||
|
|
@ -4137,9 +4223,9 @@ async def initialize( # noqa: PLR0915
|
|||
user_api_base = api_base
|
||||
dynamic_config[user_model]["api_base"] = api_base
|
||||
if api_version:
|
||||
os.environ["AZURE_API_VERSION"] = (
|
||||
api_version # set this for azure - litellm can read this from the env
|
||||
)
|
||||
os.environ[
|
||||
"AZURE_API_VERSION"
|
||||
] = api_version # set this for azure - litellm can read this from the env
|
||||
if max_tokens: # model-specific param
|
||||
dynamic_config[user_model]["max_tokens"] = max_tokens
|
||||
if temperature: # model-specific param
|
||||
|
|
@ -4657,10 +4743,14 @@ class ProxyStartupEvent:
|
|||
replace_existing=True,
|
||||
misfire_grace_time=APSCHEDULER_MISFIRE_GRACE_TIME,
|
||||
)
|
||||
verbose_proxy_logger.info("Responses cost check job scheduled successfully")
|
||||
verbose_proxy_logger.info(
|
||||
"Responses cost check job scheduled successfully"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.debug(f"Failed to setup responses cost checking: {e}")
|
||||
verbose_proxy_logger.debug(
|
||||
f"Failed to setup responses cost checking: {e}"
|
||||
)
|
||||
verbose_proxy_logger.debug(
|
||||
"Checking responses cost for LiteLLM Managed Files is an Enterprise Feature. Skipping..."
|
||||
)
|
||||
|
|
@ -4684,8 +4774,9 @@ class ProxyStartupEvent:
|
|||
"""
|
||||
Initialize the spend tracking and other background jobs
|
||||
1. CloudZero Background Job
|
||||
2. Prometheus Background Job
|
||||
3. Key Rotation Background Job
|
||||
2. Focus Background Job
|
||||
3. Prometheus Background Job
|
||||
4. Key Rotation Background Job
|
||||
|
||||
Args:
|
||||
scheduler: The scheduler to add the background jobs to
|
||||
|
|
@ -4694,11 +4785,17 @@ class ProxyStartupEvent:
|
|||
# CloudZero Background Job
|
||||
########################################################
|
||||
from litellm.integrations.cloudzero.cloudzero import CloudZeroLogger
|
||||
from litellm.integrations.focus.focus_logger import FocusLogger
|
||||
from litellm.proxy.spend_tracking.cloudzero_endpoints import is_cloudzero_setup
|
||||
|
||||
if await is_cloudzero_setup():
|
||||
await CloudZeroLogger.init_cloudzero_background_job(scheduler=scheduler)
|
||||
|
||||
########################################################
|
||||
# Focus Background Job
|
||||
########################################################
|
||||
await FocusLogger.init_focus_export_background_job(scheduler=scheduler)
|
||||
|
||||
########################################################
|
||||
# Prometheus Background Job
|
||||
########################################################
|
||||
|
|
@ -4829,6 +4926,14 @@ class ProxyStartupEvent:
|
|||
|
||||
await prisma_client.connect()
|
||||
|
||||
## Start RDS IAM token refresh background task if enabled ##
|
||||
# This proactively refreshes IAM tokens before they expire,
|
||||
# preventing the 15-minute connection failure bug (#16220)
|
||||
if hasattr(prisma_client, "db") and hasattr(
|
||||
prisma_client.db, "start_token_refresh_task"
|
||||
):
|
||||
await prisma_client.db.start_token_refresh_task()
|
||||
|
||||
## Add necessary views to proxy ##
|
||||
asyncio.create_task(
|
||||
prisma_client.check_view_exists()
|
||||
|
|
@ -5940,7 +6045,6 @@ async def realtime_websocket_endpoint(
|
|||
),
|
||||
user_api_key_dict=Depends(user_api_key_auth_websocket),
|
||||
):
|
||||
|
||||
await websocket.accept()
|
||||
|
||||
# Only use explicit parameters, not all query params
|
||||
|
|
@ -6739,7 +6843,7 @@ async def run_thread(
|
|||
if (
|
||||
"stream" in data and data["stream"] is True
|
||||
): # use generate_responses to stream responses
|
||||
return await create_streaming_response(
|
||||
return await create_response(
|
||||
generator=async_assistants_data_generator(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
response=response,
|
||||
|
|
@ -9528,9 +9632,9 @@ async def get_config_list(
|
|||
hasattr(sub_field_info, "description")
|
||||
and sub_field_info.description is not None
|
||||
):
|
||||
nested_fields[idx].field_description = (
|
||||
sub_field_info.description
|
||||
)
|
||||
nested_fields[
|
||||
idx
|
||||
].field_description = sub_field_info.description
|
||||
idx += 1
|
||||
|
||||
_stored_in_db = None
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ else:
|
|||
|
||||
unified_guardrail = UnifiedLLMGuardrails()
|
||||
|
||||
_anthropic_async_clients = {}
|
||||
|
||||
def print_verbose(print_statement):
|
||||
"""
|
||||
|
|
@ -961,6 +962,7 @@ class ProxyLogging:
|
|||
Updated data dictionary if guardrail passes, None if guardrail should be skipped
|
||||
"""
|
||||
from litellm.types.guardrails import GuardrailEventHooks
|
||||
from litellm.integrations.prometheus import PrometheusLogger
|
||||
|
||||
# Determine the event type based on call type
|
||||
event_type = GuardrailEventHooks.pre_call
|
||||
|
|
@ -973,30 +975,62 @@ class ProxyLogging:
|
|||
|
||||
guardrail_name = callback.guardrail_name
|
||||
|
||||
# Check if load balancing should be used
|
||||
if guardrail_name and self._should_use_guardrail_load_balancing(guardrail_name):
|
||||
response = await self._execute_guardrail_with_load_balancing(
|
||||
guardrail_name=guardrail_name,
|
||||
hook_type="pre_call",
|
||||
data=data,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
call_type=call_type,
|
||||
)
|
||||
else:
|
||||
# Single guardrail - execute directly
|
||||
response = await self._execute_guardrail_hook(
|
||||
callback=callback,
|
||||
hook_type="pre_call",
|
||||
data=data,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
call_type=call_type,
|
||||
)
|
||||
# Track timing and errors for prometheus metrics
|
||||
# Use time.perf_counter() for more accurate duration measurements
|
||||
guardrail_start_time = time.perf_counter()
|
||||
status = "success"
|
||||
error_type = None
|
||||
|
||||
# Process the response if one was returned
|
||||
if response is not None:
|
||||
data = await self.process_pre_call_hook_response(
|
||||
response=response, data=data, call_type=call_type
|
||||
)
|
||||
try:
|
||||
# Check if load balancing should be used
|
||||
if guardrail_name and self._should_use_guardrail_load_balancing(guardrail_name):
|
||||
response = await self._execute_guardrail_with_load_balancing(
|
||||
guardrail_name=guardrail_name,
|
||||
hook_type="pre_call",
|
||||
data=data,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
call_type=call_type,
|
||||
)
|
||||
else:
|
||||
# Single guardrail - execute directly
|
||||
response = await self._execute_guardrail_hook(
|
||||
callback=callback,
|
||||
hook_type="pre_call",
|
||||
data=data,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
call_type=call_type,
|
||||
)
|
||||
|
||||
# Process the response if one was returned
|
||||
if response is not None:
|
||||
data = await self.process_pre_call_hook_response(
|
||||
response=response, data=data, call_type=call_type
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
status = "error"
|
||||
error_type = type(e).__name__
|
||||
# Re-raise the exception to maintain existing behavior
|
||||
raise
|
||||
finally:
|
||||
# Record prometheus metrics
|
||||
guardrail_end_time = time.perf_counter()
|
||||
latency_seconds = guardrail_end_time - guardrail_start_time
|
||||
|
||||
# Get guardrail name for metrics (fallback if not set)
|
||||
metrics_guardrail_name = guardrail_name or getattr(callback, "guardrail_name", callback.__class__.__name__) or "unknown"
|
||||
|
||||
# Find PrometheusLogger in callbacks and record metrics
|
||||
for prom_callback in litellm.callbacks:
|
||||
if isinstance(prom_callback, PrometheusLogger):
|
||||
prom_callback._record_guardrail_metrics(
|
||||
guardrail_name=metrics_guardrail_name,
|
||||
latency_seconds=latency_seconds,
|
||||
status=status,
|
||||
error_type=error_type,
|
||||
hook_type="pre_call",
|
||||
)
|
||||
break
|
||||
|
||||
return data
|
||||
|
||||
|
|
@ -4254,11 +4288,16 @@ async def count_tokens_with_anthropic_api(
|
|||
|
||||
if anthropic_api_key and messages:
|
||||
# Call Anthropic API directly for more accurate token counting
|
||||
client = anthropic.Anthropic(api_key=anthropic_api_key)
|
||||
|
||||
# Use cached client if available to avoid socket exhaustion
|
||||
if anthropic_api_key not in _anthropic_async_clients:
|
||||
_anthropic_async_clients[anthropic_api_key] = anthropic.AsyncAnthropic(api_key=anthropic_api_key)
|
||||
|
||||
client = _anthropic_async_clients[anthropic_api_key]
|
||||
|
||||
# Call with explicit parameters to satisfy type checking
|
||||
# Type ignore for now since messages come from generic dict input
|
||||
response = client.beta.messages.count_tokens(
|
||||
response = await client.beta.messages.count_tokens(
|
||||
model=model_to_use,
|
||||
messages=messages, # type: ignore
|
||||
betas=["token-counting-2024-11-01"],
|
||||
|
|
|
|||
|
|
@ -443,11 +443,18 @@ class ResponseAPILoggingUtils:
|
|||
completion_tokens=0,
|
||||
total_tokens=0,
|
||||
)
|
||||
response_api_usage: ResponseAPIUsage = (
|
||||
ResponseAPIUsage(**usage_input)
|
||||
if isinstance(usage_input, dict)
|
||||
else usage_input
|
||||
)
|
||||
response_api_usage: ResponseAPIUsage
|
||||
if isinstance(usage_input, dict):
|
||||
total_tokens = usage_input.get("total_tokens")
|
||||
if total_tokens is None:
|
||||
input_tokens = usage_input.get("input_tokens")
|
||||
output_tokens = usage_input.get("output_tokens")
|
||||
if input_tokens is not None and output_tokens is not None:
|
||||
total_tokens = input_tokens + output_tokens
|
||||
usage_input["total_tokens"] = total_tokens
|
||||
response_api_usage = ResponseAPIUsage(**usage_input)
|
||||
else:
|
||||
response_api_usage = usage_input
|
||||
prompt_tokens: int = response_api_usage.input_tokens or 0
|
||||
completion_tokens: int = response_api_usage.output_tokens or 0
|
||||
prompt_tokens_details: Optional[PromptTokensDetailsWrapper] = None
|
||||
|
|
|
|||
|
|
@ -4485,9 +4485,21 @@ class Router:
|
|||
|
||||
if hasattr(original_exception, "message"):
|
||||
# add the available fallbacks to the exception
|
||||
original_exception.message += ". Received Model Group={}\nAvailable Model Group Fallbacks={}".format( # type: ignore
|
||||
model_group,
|
||||
fallback_model_group,
|
||||
deployment_info = ""
|
||||
if kwargs is not None:
|
||||
metadata = kwargs.get('metadata', {})
|
||||
if metadata and 'deployment' in metadata:
|
||||
deployment_info = f"\nUsed Deployment: {metadata['deployment']}"
|
||||
if 'model_info' in metadata:
|
||||
model_info = metadata['model_info']
|
||||
if isinstance(model_info, dict):
|
||||
deployment_info += f"\nDeployment ID: {model_info.get('id', 'unknown')}"
|
||||
|
||||
original_exception.message += ( # type: ignore
|
||||
f". Received Model Group={model_group}"
|
||||
f"\nAvailable Model Group Fallbacks={fallback_model_group}"
|
||||
f"{deployment_info}"
|
||||
f"\n\n💡 Tip: If using wildcard patterns (e.g., 'openai/*'), ensure all matching deployments have credentials with access to this model."
|
||||
)
|
||||
if len(fallback_failure_exception_str) > 0:
|
||||
original_exception.message += ( # type: ignore
|
||||
|
|
@ -7664,6 +7676,10 @@ class Router:
|
|||
)
|
||||
|
||||
if pattern_deployments:
|
||||
verbose_router_logger.debug(
|
||||
f"Pattern match for model='{model}': Found {len(pattern_deployments)} deployments. "
|
||||
f"Deployment IDs: {[d.get('model_info', {}).get('id', 'unknown') for d in pattern_deployments]}"
|
||||
)
|
||||
return model, pattern_deployments
|
||||
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import re
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Dict, List, Literal, Optional, Tuple, Union
|
||||
from typing import Dict, List, Literal, Optional, Tuple
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing_extensions import Annotated
|
||||
|
|
@ -188,6 +188,14 @@ DEFINED_PROMETHEUS_METRICS = Literal[
|
|||
"litellm_redis_daily_spend_update_queue_size",
|
||||
"litellm_in_memory_spend_update_queue_size",
|
||||
"litellm_redis_spend_update_queue_size",
|
||||
"litellm_request_queue_time_seconds",
|
||||
"litellm_guardrail_latency_seconds",
|
||||
"litellm_guardrail_errors_total",
|
||||
"litellm_guardrail_requests_total",
|
||||
# Cache metrics
|
||||
"litellm_cache_hits_metric",
|
||||
"litellm_cache_misses_metric",
|
||||
"litellm_cached_tokens_metric",
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -222,6 +230,23 @@ class PrometheusMetricLabels:
|
|||
UserAPIKeyLabelNames.v1_LITELLM_MODEL_NAME.value,
|
||||
]
|
||||
|
||||
litellm_request_queue_time_seconds = [
|
||||
UserAPIKeyLabelNames.END_USER.value,
|
||||
UserAPIKeyLabelNames.API_KEY_HASH.value,
|
||||
UserAPIKeyLabelNames.API_KEY_ALIAS.value,
|
||||
UserAPIKeyLabelNames.REQUESTED_MODEL.value,
|
||||
UserAPIKeyLabelNames.TEAM.value,
|
||||
UserAPIKeyLabelNames.TEAM_ALIAS.value,
|
||||
UserAPIKeyLabelNames.USER.value,
|
||||
UserAPIKeyLabelNames.v1_LITELLM_MODEL_NAME.value,
|
||||
]
|
||||
|
||||
# Guardrail metrics - these use custom labels (guardrail_name, status, error_type, hook_type)
|
||||
# which are not part of UserAPIKeyLabelNames
|
||||
litellm_guardrail_latency_seconds: List[str] = []
|
||||
litellm_guardrail_errors_total: List[str] = []
|
||||
litellm_guardrail_requests_total: List[str] = []
|
||||
|
||||
litellm_proxy_total_requests_metric = [
|
||||
UserAPIKeyLabelNames.END_USER.value,
|
||||
UserAPIKeyLabelNames.API_KEY_HASH.value,
|
||||
|
|
@ -451,6 +476,21 @@ class PrometheusMetricLabels:
|
|||
|
||||
litellm_redis_spend_update_queue_size: List[str] = []
|
||||
|
||||
# Cache metrics - track cache hits, misses, and tokens served from cache
|
||||
_cache_metric_labels = [
|
||||
UserAPIKeyLabelNames.v1_LITELLM_MODEL_NAME.value,
|
||||
UserAPIKeyLabelNames.API_KEY_HASH.value,
|
||||
UserAPIKeyLabelNames.API_KEY_ALIAS.value,
|
||||
UserAPIKeyLabelNames.TEAM.value,
|
||||
UserAPIKeyLabelNames.TEAM_ALIAS.value,
|
||||
UserAPIKeyLabelNames.END_USER.value,
|
||||
UserAPIKeyLabelNames.USER.value,
|
||||
]
|
||||
|
||||
litellm_cache_hits_metric = _cache_metric_labels
|
||||
litellm_cache_misses_metric = _cache_metric_labels
|
||||
litellm_cached_tokens_metric = _cache_metric_labels
|
||||
|
||||
@staticmethod
|
||||
def get_labels(label_name: DEFINED_PROMETHEUS_METRICS) -> List[str]:
|
||||
default_labels = getattr(PrometheusMetricLabels, label_name)
|
||||
|
|
@ -475,11 +515,6 @@ class PrometheusMetricLabels:
|
|||
return default_labels + custom_labels
|
||||
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class UserAPIKeyLabelValues(BaseModel):
|
||||
end_user: Annotated[
|
||||
Optional[str], Field(..., alias=UserAPIKeyLabelNames.END_USER.value)
|
||||
|
|
|
|||
|
|
@ -47,7 +47,6 @@ from tiktoken import Encoding
|
|||
from tokenizers import Tokenizer
|
||||
|
||||
import litellm
|
||||
|
||||
import litellm.litellm_core_utils
|
||||
# audio_utils.utils is lazy-loaded - only imported when needed for transcription calls
|
||||
import litellm.litellm_core_utils.json_validation_rule
|
||||
|
|
@ -7718,6 +7717,11 @@ class ProviderConfigManager:
|
|||
return litellm.CometAPIEmbeddingConfig()
|
||||
elif litellm.LlmProviders.GITHUB_COPILOT == provider:
|
||||
return litellm.GithubCopilotEmbeddingConfig()
|
||||
elif litellm.LlmProviders.OPENROUTER == provider:
|
||||
from litellm.llms.openrouter.embedding.transformation import (
|
||||
OpenrouterEmbeddingConfig,
|
||||
)
|
||||
return OpenrouterEmbeddingConfig()
|
||||
elif litellm.LlmProviders.GIGACHAT == provider:
|
||||
return litellm.GigaChatEmbeddingConfig()
|
||||
elif litellm.LlmProviders.SAGEMAKER == provider:
|
||||
|
|
@ -7941,6 +7945,8 @@ class ProviderConfigManager:
|
|||
return litellm.LemonadeChatConfig()
|
||||
elif LlmProviders.CLARIFAI == provider:
|
||||
return litellm.ClarifaiConfig()
|
||||
elif LlmProviders.BEDROCK == provider:
|
||||
return litellm.llms.bedrock.common_utils.BedrockModelInfo()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
|
|
@ -32,6 +32,23 @@
|
|||
}
|
||||
},
|
||||
"providers": {
|
||||
"abliteration": {
|
||||
"display_name": "Abliteration (`abliteration`)",
|
||||
"url": "https://docs.litellm.ai/docs/providers/abliteration",
|
||||
"endpoints": {
|
||||
"chat_completions": true,
|
||||
"messages": false,
|
||||
"responses": false,
|
||||
"embeddings": false,
|
||||
"image_generations": false,
|
||||
"audio_transcriptions": false,
|
||||
"audio_speech": false,
|
||||
"moderations": false,
|
||||
"batches": false,
|
||||
"rerank": false,
|
||||
"a2a": false
|
||||
}
|
||||
},
|
||||
"aiml": {
|
||||
"display_name": "AI/ML API (`aiml`)",
|
||||
"url": "https://docs.litellm.ai/docs/providers/aiml",
|
||||
|
|
@ -1559,7 +1576,7 @@
|
|||
"chat_completions": true,
|
||||
"messages": true,
|
||||
"responses": true,
|
||||
"embeddings": false,
|
||||
"embeddings": true,
|
||||
"image_generations": false,
|
||||
"audio_transcriptions": false,
|
||||
"audio_speech": false,
|
||||
|
|
|
|||
50
tests/litellm/llms/openai_like/test_abliteration_provider.py
Normal file
50
tests/litellm/llms/openai_like/test_abliteration_provider.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"""
|
||||
Unit tests for the Abliteration OpenAI-like provider.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(
|
||||
0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../.."))
|
||||
)
|
||||
|
||||
from litellm.llms.openai_like.dynamic_config import create_config_class
|
||||
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
|
||||
|
||||
ABLITERATION_BASE_URL = "https://api.abliteration.ai/v1"
|
||||
|
||||
|
||||
def _get_config():
|
||||
provider = JSONProviderRegistry.get("abliteration")
|
||||
assert provider is not None
|
||||
config_class = create_config_class(provider)
|
||||
return config_class()
|
||||
|
||||
|
||||
def test_abliteration_provider_registered():
|
||||
provider = JSONProviderRegistry.get("abliteration")
|
||||
assert provider is not None
|
||||
assert provider.base_url == ABLITERATION_BASE_URL
|
||||
assert provider.api_key_env == "ABLITERATION_API_KEY"
|
||||
|
||||
|
||||
def test_abliteration_resolves_env_api_key(monkeypatch):
|
||||
config = _get_config()
|
||||
monkeypatch.setenv("ABLITERATION_API_KEY", "test-key")
|
||||
api_base, api_key = config._get_openai_compatible_provider_info(None, None)
|
||||
assert api_base == ABLITERATION_BASE_URL
|
||||
assert api_key == "test-key"
|
||||
|
||||
|
||||
def test_abliteration_complete_url_appends_endpoint():
|
||||
config = _get_config()
|
||||
url = config.get_complete_url(
|
||||
api_base=ABLITERATION_BASE_URL,
|
||||
api_key="test-key",
|
||||
model="abliteration/abliterated-model",
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
stream=False,
|
||||
)
|
||||
assert url == f"{ABLITERATION_BASE_URL}/chat/completions"
|
||||
|
|
@ -7,6 +7,7 @@ sys.path.insert(
|
|||
0, os.path.abspath("../../../../..")
|
||||
) # Adds the parent directory to the system path
|
||||
from litellm.llms.vertex_ai.gemini import transformation
|
||||
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexGeminiConfig
|
||||
from litellm.types.llms import openai
|
||||
from litellm.types import completion
|
||||
from litellm.types.llms.vertex_ai import RequestBody
|
||||
|
|
@ -225,4 +226,65 @@ async def test__transform_request_body_image_config_with_image_size():
|
|||
assert "generationConfig" in rb
|
||||
assert "imageConfig" in rb["generationConfig"]
|
||||
assert rb["generationConfig"]["imageConfig"]["aspectRatio"] == "16:9"
|
||||
assert rb["generationConfig"]["imageConfig"]["imageSize"] == "4K"
|
||||
assert rb["generationConfig"]["imageConfig"]["imageSize"] == "4K"
|
||||
|
||||
|
||||
def test_map_function_google_search_snake_case():
|
||||
"""
|
||||
Test that google_search tool (snake_case) is properly mapped to googleSearch.
|
||||
Fixes issue where tools=[{"google_search": {}}] was being stripped.
|
||||
"""
|
||||
config = VertexGeminiConfig()
|
||||
optional_params = {}
|
||||
|
||||
# Test snake_case google_search
|
||||
tools = [{"google_search": {}}]
|
||||
result = config._map_function(tools, optional_params)
|
||||
|
||||
assert len(result) == 1
|
||||
assert "googleSearch" in result[0]
|
||||
assert result[0]["googleSearch"] == {}
|
||||
|
||||
|
||||
def test_map_function_google_search_camel_case():
|
||||
"""
|
||||
Test that googleSearch tool (camelCase) still works.
|
||||
"""
|
||||
config = VertexGeminiConfig()
|
||||
optional_params = {}
|
||||
|
||||
# Test camelCase googleSearch
|
||||
tools = [{"googleSearch": {}}]
|
||||
result = config._map_function(tools, optional_params)
|
||||
|
||||
assert len(result) == 1
|
||||
assert "googleSearch" in result[0]
|
||||
assert result[0]["googleSearch"] == {}
|
||||
|
||||
|
||||
def test_map_function_google_search_retrieval_snake_case():
|
||||
"""
|
||||
Test that google_search_retrieval tool (snake_case) is properly mapped.
|
||||
"""
|
||||
config = VertexGeminiConfig()
|
||||
optional_params = {}
|
||||
|
||||
tools = [{"google_search_retrieval": {"dynamic_retrieval_config": {"mode": "MODE_DYNAMIC"}}}]
|
||||
result = config._map_function(tools, optional_params)
|
||||
|
||||
assert len(result) == 1
|
||||
assert "googleSearchRetrieval" in result[0]
|
||||
|
||||
|
||||
def test_map_function_enterprise_web_search_snake_case():
|
||||
"""
|
||||
Test that enterprise_web_search tool (snake_case) is properly mapped.
|
||||
"""
|
||||
config = VertexGeminiConfig()
|
||||
optional_params = {}
|
||||
|
||||
tools = [{"enterprise_web_search": {}}]
|
||||
result = config._map_function(tools, optional_params)
|
||||
|
||||
assert len(result) == 1
|
||||
assert "enterpriseWebSearch" in result[0]
|
||||
182
tests/llm_translation/test_bedrock_common_utils.py
Normal file
182
tests/llm_translation/test_bedrock_common_utils.py
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
"""
|
||||
Unit tests for litellm/llms/bedrock/common_utils.py
|
||||
|
||||
Tests the standalone model name utility functions and BedrockTokenCounter.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.llms.bedrock.common_utils import (
|
||||
BedrockModelInfo,
|
||||
extract_model_name_from_bedrock_arn,
|
||||
get_bedrock_base_model,
|
||||
get_bedrock_cross_region_inference_regions,
|
||||
strip_bedrock_routing_prefix,
|
||||
)
|
||||
from litellm.llms.bedrock.count_tokens.bedrock_token_counter import BedrockTokenCounter
|
||||
|
||||
|
||||
class TestStripBedrockRoutingPrefix:
|
||||
"""Tests for strip_bedrock_routing_prefix function."""
|
||||
|
||||
def test_strips_bedrock_prefix(self):
|
||||
assert strip_bedrock_routing_prefix("bedrock/claude-3-sonnet") == "claude-3-sonnet"
|
||||
|
||||
def test_strips_converse_prefix(self):
|
||||
assert strip_bedrock_routing_prefix("converse/claude-3-sonnet") == "claude-3-sonnet"
|
||||
|
||||
def test_strips_invoke_prefix(self):
|
||||
assert strip_bedrock_routing_prefix("invoke/claude-3-sonnet") == "claude-3-sonnet"
|
||||
|
||||
def test_strips_openai_prefix(self):
|
||||
assert strip_bedrock_routing_prefix("openai/gpt-4") == "gpt-4"
|
||||
|
||||
def test_strips_all_known_prefixes(self):
|
||||
# Function strips all known prefixes iteratively
|
||||
# bedrock/converse/model -> converse/model -> model
|
||||
assert strip_bedrock_routing_prefix("bedrock/converse/claude-3") == "claude-3"
|
||||
|
||||
def test_no_prefix_unchanged(self):
|
||||
assert strip_bedrock_routing_prefix("claude-3-sonnet") == "claude-3-sonnet"
|
||||
|
||||
def test_model_with_dots_unchanged(self):
|
||||
assert (
|
||||
strip_bedrock_routing_prefix("anthropic.claude-3-sonnet-20240229-v1:0")
|
||||
== "anthropic.claude-3-sonnet-20240229-v1:0"
|
||||
)
|
||||
|
||||
|
||||
class TestExtractModelNameFromBedrockArn:
|
||||
"""Tests for extract_model_name_from_bedrock_arn function."""
|
||||
|
||||
def test_extracts_from_provisioned_model_arn(self):
|
||||
arn = "arn:aws:bedrock:us-east-1:123456789012:provisioned-model/my-model-id"
|
||||
assert extract_model_name_from_bedrock_arn(arn) == "my-model-id"
|
||||
|
||||
def test_extracts_from_foundation_model_arn(self):
|
||||
arn = "arn:aws:bedrock:us-west-2:123456789012:foundation-model/anthropic.claude-v2"
|
||||
assert extract_model_name_from_bedrock_arn(arn) == "anthropic.claude-v2"
|
||||
|
||||
def test_non_arn_unchanged(self):
|
||||
model = "anthropic.claude-3-sonnet-20240229-v1:0"
|
||||
assert extract_model_name_from_bedrock_arn(model) == model
|
||||
|
||||
def test_case_insensitive_arn_detection(self):
|
||||
arn = "ARN:aws:bedrock:us-east-1:123456789012:model/my-model"
|
||||
assert extract_model_name_from_bedrock_arn(arn) == "my-model"
|
||||
|
||||
|
||||
class TestGetBedrockCrossRegionInferenceRegions:
|
||||
"""Tests for get_bedrock_cross_region_inference_regions function."""
|
||||
|
||||
def test_returns_expected_regions(self):
|
||||
regions = get_bedrock_cross_region_inference_regions()
|
||||
assert "us" in regions
|
||||
assert "eu" in regions
|
||||
assert "global" in regions
|
||||
assert "apac" in regions
|
||||
|
||||
def test_returns_list(self):
|
||||
regions = get_bedrock_cross_region_inference_regions()
|
||||
assert isinstance(regions, list)
|
||||
|
||||
|
||||
class TestGetBedrockBaseModel:
|
||||
"""Tests for get_bedrock_base_model function."""
|
||||
|
||||
def test_strips_bedrock_prefix(self):
|
||||
assert get_bedrock_base_model("bedrock/claude-3-sonnet") == "claude-3-sonnet"
|
||||
|
||||
def test_strips_converse_prefix(self):
|
||||
assert get_bedrock_base_model("bedrock/converse/claude-3-sonnet") == "claude-3-sonnet"
|
||||
|
||||
def test_strips_us_region_prefix(self):
|
||||
# us.anthropic.model -> anthropic.model
|
||||
assert (
|
||||
get_bedrock_base_model("us.anthropic.claude-3-sonnet-20240229-v1:0")
|
||||
== "anthropic.claude-3-sonnet-20240229-v1:0"
|
||||
)
|
||||
|
||||
def test_strips_eu_region_prefix(self):
|
||||
assert (
|
||||
get_bedrock_base_model("eu.anthropic.claude-3-sonnet-20240229-v1:0")
|
||||
== "anthropic.claude-3-sonnet-20240229-v1:0"
|
||||
)
|
||||
|
||||
def test_extracts_from_arn(self):
|
||||
arn = "arn:aws:bedrock:us-east-1:123456789012:provisioned-model/my-model"
|
||||
assert get_bedrock_base_model(arn) == "my-model"
|
||||
|
||||
def test_model_without_prefix_unchanged(self):
|
||||
model = "anthropic.claude-3-sonnet-20240229-v1:0"
|
||||
assert get_bedrock_base_model(model) == model
|
||||
|
||||
def test_combined_bedrock_and_region_prefix(self):
|
||||
# bedrock/us.anthropic.model -> anthropic.model
|
||||
assert (
|
||||
get_bedrock_base_model("bedrock/us.anthropic.claude-3-sonnet-20240229-v1:0")
|
||||
== "anthropic.claude-3-sonnet-20240229-v1:0"
|
||||
)
|
||||
|
||||
|
||||
class TestBedrockModelInfoWrappers:
|
||||
"""Tests that BedrockModelInfo methods correctly wrap standalone functions."""
|
||||
|
||||
def test_get_base_model_matches_standalone(self):
|
||||
test_cases = [
|
||||
"bedrock/claude-3-sonnet",
|
||||
"us.anthropic.claude-3-sonnet-20240229-v1:0",
|
||||
"arn:aws:bedrock:us-east-1:123:model/my-model",
|
||||
]
|
||||
for model in test_cases:
|
||||
assert BedrockModelInfo.get_base_model(model) == get_bedrock_base_model(model)
|
||||
|
||||
def test_extract_model_name_from_arn_matches_standalone(self):
|
||||
arn = "arn:aws:bedrock:us-east-1:123456789012:provisioned-model/my-model"
|
||||
assert (
|
||||
BedrockModelInfo.extract_model_name_from_arn(arn)
|
||||
== extract_model_name_from_bedrock_arn(arn)
|
||||
)
|
||||
|
||||
def test_get_non_litellm_routing_model_name_matches_standalone(self):
|
||||
model = "bedrock/converse/claude-3"
|
||||
assert (
|
||||
BedrockModelInfo.get_non_litellm_routing_model_name(model)
|
||||
== strip_bedrock_routing_prefix(model)
|
||||
)
|
||||
|
||||
|
||||
class TestBedrockTokenCounter:
|
||||
"""Tests for BedrockTokenCounter class."""
|
||||
|
||||
def test_should_use_token_counting_api_for_bedrock(self):
|
||||
counter = BedrockTokenCounter()
|
||||
assert counter.should_use_token_counting_api("bedrock") is True
|
||||
|
||||
def test_should_not_use_token_counting_api_for_other_providers(self):
|
||||
counter = BedrockTokenCounter()
|
||||
assert counter.should_use_token_counting_api("openai") is False
|
||||
assert counter.should_use_token_counting_api("anthropic") is False
|
||||
assert counter.should_use_token_counting_api(None) is False
|
||||
|
||||
def test_get_token_counter_returns_bedrock_token_counter(self):
|
||||
model_info = BedrockModelInfo()
|
||||
token_counter = model_info.get_token_counter()
|
||||
assert isinstance(token_counter, BedrockTokenCounter)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_count_tokens_returns_none_for_empty_messages(self):
|
||||
counter = BedrockTokenCounter()
|
||||
result = await counter.count_tokens(
|
||||
model_to_use="anthropic.claude-3-sonnet",
|
||||
messages=None,
|
||||
contents=None,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
result = await counter.count_tokens(
|
||||
model_to_use="anthropic.claude-3-sonnet",
|
||||
messages=[],
|
||||
contents=None,
|
||||
)
|
||||
assert result is None
|
||||
|
|
@ -2835,6 +2835,34 @@ def test_bedrock_invoke_provider():
|
|||
)
|
||||
== "nova"
|
||||
)
|
||||
assert (
|
||||
litellm.AmazonInvokeConfig().get_bedrock_invoke_provider("amazon.nova-pro-v1:0")
|
||||
== "nova"
|
||||
)
|
||||
assert (
|
||||
litellm.AmazonInvokeConfig().get_bedrock_invoke_provider(
|
||||
"amazon.nova-lite-v1:0"
|
||||
)
|
||||
== "nova"
|
||||
)
|
||||
assert (
|
||||
litellm.AmazonInvokeConfig().get_bedrock_invoke_provider(
|
||||
"amazon.nova-micro-v1:0"
|
||||
)
|
||||
== "nova"
|
||||
)
|
||||
assert (
|
||||
litellm.AmazonInvokeConfig().get_bedrock_invoke_provider(
|
||||
"amazon.nova-premier-v1:0"
|
||||
)
|
||||
== "nova"
|
||||
)
|
||||
assert (
|
||||
litellm.AmazonInvokeConfig().get_bedrock_invoke_provider(
|
||||
"amazon.nova-2-lite-v1:0"
|
||||
)
|
||||
== "nova"
|
||||
)
|
||||
|
||||
|
||||
def test_bedrock_description_param():
|
||||
|
|
@ -3488,7 +3516,9 @@ def test_bedrock_openai_imported_model():
|
|||
url = mock_post.call_args.kwargs["url"]
|
||||
print(f"URL: {url}")
|
||||
assert "bedrock-runtime.us-east-1.amazonaws.com" in url
|
||||
assert "arn:aws:bedrock:us-east-1:117159858402:imported-model/m4gc1mrfuddy" in url
|
||||
assert (
|
||||
"arn:aws:bedrock:us-east-1:117159858402:imported-model/m4gc1mrfuddy" in url
|
||||
)
|
||||
assert "/invoke" in url
|
||||
|
||||
# Validate request body follows OpenAI format
|
||||
|
|
@ -3517,7 +3547,9 @@ def test_bedrock_openai_imported_model():
|
|||
# Check image_url content
|
||||
assert user_msg["content"][1]["type"] == "image_url"
|
||||
assert "image_url" in user_msg["content"][1]
|
||||
assert user_msg["content"][1]["image_url"]["url"].startswith("data:image/jpeg;base64,")
|
||||
assert user_msg["content"][1]["image_url"]["url"].startswith(
|
||||
"data:image/jpeg;base64,"
|
||||
)
|
||||
|
||||
assert user_msg["content"][2]["type"] == "image_url"
|
||||
assert "image_url" in user_msg["content"][2]
|
||||
|
|
@ -3526,21 +3558,67 @@ def test_bedrock_openai_imported_model():
|
|||
assert request_body["max_tokens"] == 300
|
||||
assert request_body["temperature"] == 0.5
|
||||
|
||||
|
||||
def test_bedrock_nova_provider_detection():
|
||||
"""
|
||||
Test that Nova models are correctly detected even when prefixed with "amazon."
|
||||
Regression test for issue #17910 where models like "amazon.nova-pro-v1:0"
|
||||
were incorrectly identified as "amazon" (Titan) instead of "nova".
|
||||
"""
|
||||
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
|
||||
|
||||
# Test various Nova model formats
|
||||
nova_test_cases = [
|
||||
("us.amazon.nova-pro-v1:0", "nova"),
|
||||
("us.amazon.nova-lite-v1:0", "nova"),
|
||||
("us.amazon.nova-micro-v1:0", "nova"),
|
||||
("amazon.nova-pro-v1:0", "nova"),
|
||||
("amazon.nova-lite-v1:0", "nova"),
|
||||
("amazon.nova-micro-v1:0", "nova"),
|
||||
("amazon.nova-premier-v1:0", "nova"),
|
||||
("amazon.nova-2-lite-v1:0", "nova"),
|
||||
("bedrock/amazon.nova-pro-v1:0", "nova"),
|
||||
("bedrock/invoke/amazon.nova-pro-v1:0", "nova"),
|
||||
("amazon.Nova-pro-v1:0", "nova"),
|
||||
("amazon.NOVA-pro-v1:0", "nova"),
|
||||
]
|
||||
|
||||
for model, expected in nova_test_cases:
|
||||
provider = BaseAWSLLM.get_bedrock_invoke_provider(model)
|
||||
assert (
|
||||
provider == expected
|
||||
), f"Failed for model: {model}, expected: {expected}, got: {provider}"
|
||||
|
||||
# Verify that Amazon Titan models still return "amazon"
|
||||
titan_test_cases = [
|
||||
("amazon.titan-text-express-v1", "amazon"),
|
||||
("us.amazon.titan-text-lite-v1", "amazon"),
|
||||
]
|
||||
|
||||
for model, expected in titan_test_cases:
|
||||
provider = BaseAWSLLM.get_bedrock_invoke_provider(model)
|
||||
assert (
|
||||
provider == expected
|
||||
), f"Failed for model: {model}, expected: {expected}, got: {provider}"
|
||||
|
||||
|
||||
def test_bedrock_openai_provider_detection():
|
||||
"""
|
||||
Test that the OpenAI provider is correctly detected from model strings.
|
||||
"""
|
||||
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
|
||||
|
||||
|
||||
# Test various OpenAI model formats
|
||||
test_cases = [
|
||||
"openai/arn:aws:bedrock:us-east-1:123456789012:imported-model/abc123",
|
||||
"bedrock/openai/arn:aws:bedrock:us-east-1:123456789012:imported-model/xyz789",
|
||||
]
|
||||
|
||||
|
||||
for model in test_cases:
|
||||
provider = BaseAWSLLM.get_bedrock_invoke_provider(model)
|
||||
assert provider == "openai", f"Failed for model: {model}, got provider: {provider}"
|
||||
assert (
|
||||
provider == "openai"
|
||||
), f"Failed for model: {model}, got provider: {provider}"
|
||||
print(f"✓ Provider detection works for: {model}")
|
||||
|
||||
|
||||
|
|
@ -3549,16 +3627,16 @@ def test_bedrock_openai_model_id_extraction():
|
|||
Test that the model ID (ARN) is correctly extracted and encoded for OpenAI models.
|
||||
"""
|
||||
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
|
||||
|
||||
model = "openai/arn:aws:bedrock:us-east-1:123456789012:imported-model/test-model-123"
|
||||
provider = BaseAWSLLM.get_bedrock_invoke_provider(model)
|
||||
|
||||
model_id = BaseAWSLLM.get_bedrock_model_id(
|
||||
model=model,
|
||||
provider=provider,
|
||||
optional_params={}
|
||||
|
||||
model = (
|
||||
"openai/arn:aws:bedrock:us-east-1:123456789012:imported-model/test-model-123"
|
||||
)
|
||||
|
||||
provider = BaseAWSLLM.get_bedrock_invoke_provider(model)
|
||||
|
||||
model_id = BaseAWSLLM.get_bedrock_model_id(
|
||||
model=model, provider=provider, optional_params={}
|
||||
)
|
||||
|
||||
# The ARN should be double URL encoded
|
||||
assert "arn" in model_id
|
||||
assert "imported-model" in model_id
|
||||
|
|
@ -3570,20 +3648,17 @@ def test_bedrock_openai_convert_messages_to_prompt():
|
|||
Test that convert_messages_to_prompt returns empty string for OpenAI models.
|
||||
"""
|
||||
from litellm.llms.bedrock.chat.invoke_handler import BedrockLLM
|
||||
|
||||
|
||||
bedrock_llm = BedrockLLM()
|
||||
messages = [
|
||||
{"role": "system", "content": "You are helpful"},
|
||||
{"role": "user", "content": "Hello"}
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
|
||||
|
||||
prompt, chat_history = bedrock_llm.convert_messages_to_prompt(
|
||||
model="test-model",
|
||||
messages=messages,
|
||||
provider="openai",
|
||||
custom_prompt_dict={}
|
||||
model="test-model", messages=messages, provider="openai", custom_prompt_dict={}
|
||||
)
|
||||
|
||||
|
||||
# OpenAI models use messages directly, no prompt conversion
|
||||
assert prompt == ""
|
||||
assert chat_history is None
|
||||
|
|
@ -3598,37 +3673,33 @@ def test_bedrock_openai_response_parsing():
|
|||
from litellm import ModelResponse
|
||||
from unittest.mock import Mock
|
||||
import json
|
||||
|
||||
|
||||
bedrock_llm = BedrockLLM()
|
||||
|
||||
|
||||
# Mock OpenAI-style response
|
||||
openai_response = {
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"content": "The capital of France is Paris.",
|
||||
"role": "assistant"
|
||||
"role": "assistant",
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
"index": 0
|
||||
"index": 0,
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 10,
|
||||
"completion_tokens": 8,
|
||||
"total_tokens": 18
|
||||
}
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 8, "total_tokens": 18},
|
||||
}
|
||||
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = openai_response
|
||||
mock_response.text = json.dumps(openai_response)
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {}
|
||||
|
||||
|
||||
model_response = ModelResponse()
|
||||
mock_logging = Mock()
|
||||
|
||||
|
||||
result = bedrock_llm.process_response(
|
||||
model="openai/arn:aws:bedrock:us-east-1:123:imported-model/test",
|
||||
response=mock_response,
|
||||
|
|
@ -3640,18 +3711,18 @@ def test_bedrock_openai_response_parsing():
|
|||
data={},
|
||||
messages=[{"role": "user", "content": "What is the capital of France?"}],
|
||||
print_verbose=lambda x: None,
|
||||
encoding=None
|
||||
encoding=None,
|
||||
)
|
||||
|
||||
|
||||
# Verify response content
|
||||
assert result.choices[0].message.content == "The capital of France is Paris."
|
||||
assert result.choices[0].finish_reason == "stop"
|
||||
|
||||
|
||||
# Verify usage
|
||||
assert result.usage.prompt_tokens == 10
|
||||
assert result.usage.completion_tokens == 8
|
||||
assert result.usage.total_tokens == 18
|
||||
|
||||
|
||||
print("✓ OpenAI response parsing works correctly")
|
||||
|
||||
|
||||
|
|
@ -3659,45 +3730,47 @@ def test_bedrock_openai_request_transformation():
|
|||
"""
|
||||
Test that the request is correctly transformed for OpenAI models.
|
||||
"""
|
||||
from litellm.llms.bedrock.chat.invoke_transformations.base_invoke_transformation import AmazonInvokeConfig
|
||||
|
||||
from litellm.llms.bedrock.chat.invoke_transformations.base_invoke_transformation import (
|
||||
AmazonInvokeConfig,
|
||||
)
|
||||
|
||||
config = AmazonInvokeConfig()
|
||||
|
||||
|
||||
model = "openai/arn:aws:bedrock:us-east-1:123:imported-model/test"
|
||||
messages = [
|
||||
{"role": "system", "content": "You are helpful"},
|
||||
{"role": "user", "content": "Hello"}
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
|
||||
|
||||
optional_params = {
|
||||
"max_tokens": 100,
|
||||
"temperature": 0.7,
|
||||
"top_p": 0.9,
|
||||
"stream": False
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
|
||||
litellm_params = {}
|
||||
headers = {}
|
||||
|
||||
with patch.object(config, 'get_bedrock_invoke_provider', return_value="openai"):
|
||||
|
||||
with patch.object(config, "get_bedrock_invoke_provider", return_value="openai"):
|
||||
result = config.transform_request(
|
||||
model=model,
|
||||
messages=messages,
|
||||
optional_params=optional_params.copy(),
|
||||
litellm_params=litellm_params,
|
||||
headers=headers
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
# Verify the request uses messages format (not prompt)
|
||||
assert "messages" in result
|
||||
assert len(result["messages"]) == 2
|
||||
assert result["messages"][0]["role"] == "system"
|
||||
assert result["messages"][1]["role"] == "user"
|
||||
|
||||
|
||||
# Verify parameters are included
|
||||
assert "max_tokens" in result
|
||||
assert "temperature" in result
|
||||
|
||||
|
||||
print("✓ Request transformation works correctly")
|
||||
|
||||
|
||||
|
|
@ -3705,20 +3778,22 @@ def test_bedrock_openai_parameter_filtering():
|
|||
"""
|
||||
Test that only supported OpenAI parameters are included in the request.
|
||||
"""
|
||||
from litellm.llms.bedrock.chat.invoke_transformations.amazon_openai_transformation import AmazonBedrockOpenAIConfig
|
||||
|
||||
from litellm.llms.bedrock.chat.invoke_transformations.amazon_openai_transformation import (
|
||||
AmazonBedrockOpenAIConfig,
|
||||
)
|
||||
|
||||
config = AmazonBedrockOpenAIConfig()
|
||||
model = "test-model"
|
||||
|
||||
|
||||
supported_params = config.get_supported_openai_params(model=model)
|
||||
|
||||
|
||||
# Verify common OpenAI parameters are supported
|
||||
assert "max_tokens" in supported_params
|
||||
assert "temperature" in supported_params
|
||||
assert "top_p" in supported_params
|
||||
assert "stream" in supported_params
|
||||
assert "stop" in supported_params
|
||||
|
||||
|
||||
print(f"✓ Parameter filtering supports: {len(supported_params)} parameters")
|
||||
print(f" Supported params: {supported_params}")
|
||||
|
||||
|
|
@ -3728,12 +3803,12 @@ def test_bedrock_openai_route_detection():
|
|||
Test that the OpenAI route is correctly detected.
|
||||
"""
|
||||
from litellm.llms.bedrock.common_utils import BedrockModelInfo
|
||||
|
||||
|
||||
test_cases = [
|
||||
("openai/arn:aws:bedrock:us-east-1:123:imported-model/test", "openai"),
|
||||
("bedrock/openai/arn:aws:bedrock:us-east-1:123:imported-model/test", "openai"),
|
||||
]
|
||||
|
||||
|
||||
for model, expected_route in test_cases:
|
||||
route = BedrockModelInfo.get_bedrock_route(model)
|
||||
assert route == expected_route, f"Failed for model: {model}, got route: {route}"
|
||||
|
|
@ -3745,15 +3820,30 @@ def test_bedrock_openai_explicit_route_check():
|
|||
Test the explicit OpenAI route checker helper method.
|
||||
"""
|
||||
from litellm.llms.bedrock.common_utils import BedrockModelInfo
|
||||
|
||||
|
||||
# Test with openai/ prefix
|
||||
assert BedrockModelInfo._explicit_openai_route("openai/arn:aws:bedrock:us-east-1:123:imported-model/test") is True
|
||||
assert BedrockModelInfo._explicit_openai_route("bedrock/openai/arn:aws:bedrock:us-east-1:123:imported-model/test") is True
|
||||
|
||||
assert (
|
||||
BedrockModelInfo._explicit_openai_route(
|
||||
"openai/arn:aws:bedrock:us-east-1:123:imported-model/test"
|
||||
)
|
||||
is True
|
||||
)
|
||||
assert (
|
||||
BedrockModelInfo._explicit_openai_route(
|
||||
"bedrock/openai/arn:aws:bedrock:us-east-1:123:imported-model/test"
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
# Test without openai/ prefix
|
||||
assert BedrockModelInfo._explicit_openai_route("anthropic.claude-3-sonnet") is False
|
||||
assert BedrockModelInfo._explicit_openai_route("arn:aws:bedrock:us-east-1:123:imported-model/test") is False
|
||||
|
||||
assert (
|
||||
BedrockModelInfo._explicit_openai_route(
|
||||
"arn:aws:bedrock:us-east-1:123:imported-model/test"
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
print("✓ Explicit route check works correctly")
|
||||
|
||||
|
||||
|
|
@ -3761,16 +3851,18 @@ def test_bedrock_openai_config_initialization():
|
|||
"""
|
||||
Test that AmazonBedrockOpenAIConfig can be properly initialized.
|
||||
"""
|
||||
from litellm.llms.bedrock.chat.invoke_transformations.amazon_openai_transformation import AmazonBedrockOpenAIConfig
|
||||
|
||||
from litellm.llms.bedrock.chat.invoke_transformations.amazon_openai_transformation import (
|
||||
AmazonBedrockOpenAIConfig,
|
||||
)
|
||||
|
||||
config = AmazonBedrockOpenAIConfig()
|
||||
|
||||
|
||||
# Verify it has the necessary methods
|
||||
assert hasattr(config, 'get_supported_openai_params')
|
||||
assert hasattr(config, 'transform_request')
|
||||
assert hasattr(config, 'transform_response')
|
||||
assert hasattr(config, 'map_openai_params')
|
||||
|
||||
assert hasattr(config, "get_supported_openai_params")
|
||||
assert hasattr(config, "transform_request")
|
||||
assert hasattr(config, "transform_response")
|
||||
assert hasattr(config, "map_openai_params")
|
||||
|
||||
print("✓ AmazonBedrockOpenAIConfig initializes correctly")
|
||||
|
||||
|
||||
|
|
@ -3779,9 +3871,9 @@ def test_bedrock_openai_multiple_message_types():
|
|||
Test that various message content types are handled correctly.
|
||||
"""
|
||||
from litellm.llms.custom_httpx.http_handler import HTTPHandler
|
||||
|
||||
|
||||
client = HTTPHandler()
|
||||
|
||||
|
||||
# Test with mixed content types
|
||||
messages = [
|
||||
{"role": "system", "content": "You are helpful"},
|
||||
|
|
@ -3790,11 +3882,14 @@ def test_bedrock_openai_multiple_message_types():
|
|||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Complex message with text"},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,iVBORw0KGg"}}
|
||||
]
|
||||
}
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "data:image/jpeg;base64,iVBORw0KGg"},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
with patch.object(client, "post") as mock_post:
|
||||
try:
|
||||
response = completion(
|
||||
|
|
@ -3805,18 +3900,18 @@ def test_bedrock_openai_multiple_message_types():
|
|||
)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
|
||||
# Verify the request was made
|
||||
if mock_post.called:
|
||||
request_body = json.loads(mock_post.call_args.kwargs["data"])
|
||||
|
||||
|
||||
# Verify messages are preserved
|
||||
assert "messages" in request_body
|
||||
assert len(request_body["messages"]) == 3
|
||||
|
||||
|
||||
# Verify mixed content is handled
|
||||
assert isinstance(request_body["messages"][2]["content"], list)
|
||||
|
||||
|
||||
print("✓ Multiple message types handled correctly")
|
||||
|
||||
|
||||
|
|
@ -3829,18 +3924,18 @@ def test_bedrock_openai_error_handling():
|
|||
from litellm.llms.bedrock.common_utils import BedrockError
|
||||
from unittest.mock import Mock
|
||||
import json
|
||||
|
||||
|
||||
bedrock_llm = BedrockLLM()
|
||||
|
||||
|
||||
# Mock error response
|
||||
mock_response = Mock()
|
||||
mock_response.json.side_effect = Exception("Invalid JSON")
|
||||
mock_response.text = "Invalid response"
|
||||
mock_response.status_code = 422
|
||||
|
||||
|
||||
model_response = ModelResponse()
|
||||
mock_logging = Mock()
|
||||
|
||||
|
||||
with pytest.raises(BedrockError) as exc_info:
|
||||
bedrock_llm.process_response(
|
||||
model="openai/arn:aws:bedrock:us-east-1:123:imported-model/test",
|
||||
|
|
@ -3853,8 +3948,8 @@ def test_bedrock_openai_error_handling():
|
|||
data={},
|
||||
messages=[],
|
||||
print_verbose=lambda x: None,
|
||||
encoding=None
|
||||
encoding=None,
|
||||
)
|
||||
|
||||
|
||||
assert exc_info.value.status_code == 422
|
||||
print("✓ Error handling works correctly")
|
||||
|
|
|
|||
|
|
@ -32,3 +32,18 @@ def test_completion_openrouter_image_generation():
|
|||
.message.images[0]["image_url"]["url"]
|
||||
.startswith("data:image/png;base64,")
|
||||
)
|
||||
|
||||
|
||||
def test_openrouter_embedding():
|
||||
"""Test OpenRouter embeddings support."""
|
||||
litellm._turn_on_debug()
|
||||
resp = litellm.embedding(
|
||||
model="openrouter/openai/text-embedding-3-small",
|
||||
input=["Hello world", "How are you?"],
|
||||
)
|
||||
print(resp)
|
||||
assert resp is not None
|
||||
assert len(resp.data) == 2
|
||||
assert resp.data[0]["embedding"] is not None
|
||||
assert isinstance(resp.data[0]["embedding"], list)
|
||||
assert len(resp.data[0]["embedding"]) > 0
|
||||
|
|
|
|||
|
|
@ -66,16 +66,77 @@ class TestCloudZeroHourlyExport:
|
|||
fake_client = MagicMock()
|
||||
fake_db = MagicMock()
|
||||
|
||||
async def query_raw_mock(query: str):
|
||||
sql_context = pl.SQLContext(
|
||||
LiteLLM_DailyUserSpend=spend_mock_data,
|
||||
LiteLLM_VerificationToken=verification_mock_data,
|
||||
LiteLLM_TeamTable=team_mock_data,
|
||||
LiteLLM_UserTable=user_mock_data,
|
||||
)
|
||||
result = sql_context.execute(query).collect()
|
||||
async def query_raw_mock(query: str, *params):
|
||||
start_time_utc = params[0] if len(params) > 0 else None
|
||||
end_time_utc = params[1] if len(params) > 1 else None
|
||||
limit = params[2] if len(params) > 2 else None
|
||||
|
||||
return result
|
||||
spend_df = spend_mock_data.collect()
|
||||
verification_df = verification_mock_data.collect().rename(
|
||||
{"key_alias": "api_key_alias"}
|
||||
)
|
||||
team_df = team_mock_data.collect()
|
||||
user_df = user_mock_data.collect()
|
||||
|
||||
joined = (
|
||||
spend_df.join(
|
||||
verification_df, left_on="api_key", right_on="token", how="left"
|
||||
)
|
||||
.join(
|
||||
team_df,
|
||||
left_on="team_id",
|
||||
right_on="team_id",
|
||||
how="left",
|
||||
suffix="_team",
|
||||
)
|
||||
.join(
|
||||
user_df,
|
||||
left_on="user_id",
|
||||
right_on="user_id",
|
||||
how="left",
|
||||
suffix="_user",
|
||||
)
|
||||
)
|
||||
|
||||
for duplicate_column in ("team_id_team", "user_id_user"):
|
||||
if duplicate_column in joined.columns:
|
||||
joined = joined.drop(duplicate_column)
|
||||
|
||||
if start_time_utc is not None:
|
||||
joined = joined.filter(pl.col("updated_at") >= start_time_utc)
|
||||
if end_time_utc is not None:
|
||||
joined = joined.filter(pl.col("updated_at") <= end_time_utc)
|
||||
|
||||
joined = joined.select(
|
||||
[
|
||||
"id",
|
||||
"date",
|
||||
"user_id",
|
||||
"api_key",
|
||||
"model",
|
||||
"model_group",
|
||||
"custom_llm_provider",
|
||||
"prompt_tokens",
|
||||
"completion_tokens",
|
||||
"spend",
|
||||
"api_requests",
|
||||
"successful_requests",
|
||||
"failed_requests",
|
||||
"cache_creation_input_tokens",
|
||||
"cache_read_input_tokens",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"team_id",
|
||||
"api_key_alias",
|
||||
"team_alias",
|
||||
"user_email",
|
||||
]
|
||||
).sort(["date", "created_at"], descending=[True, True])
|
||||
|
||||
if limit is not None:
|
||||
joined = joined.head(int(limit))
|
||||
|
||||
return joined
|
||||
|
||||
fake_db.query_raw = AsyncMock(side_effect=query_raw_mock)
|
||||
fake_client.db = fake_db
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
"""Tests for LiteLLM CloudZero database helper."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.integrations.cloudzero.database import LiteLLMDatabase
|
||||
|
||||
|
||||
def _setup_db(monkeypatch: pytest.MonkeyPatch, query_return):
|
||||
"""Return a database instance with prisma client mocked out."""
|
||||
query_mock = AsyncMock(return_value=query_return)
|
||||
mock_client = SimpleNamespace(db=SimpleNamespace(query_raw=query_mock))
|
||||
db = LiteLLMDatabase()
|
||||
monkeypatch.setattr(db, "_ensure_prisma_client", lambda: mock_client)
|
||||
return db, query_mock
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_usage_data_parameterized(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Start/end filters and limit should be parameterized via placeholders."""
|
||||
start = datetime(2024, 5, 1, tzinfo=timezone.utc)
|
||||
end = datetime(2024, 5, 2, tzinfo=timezone.utc)
|
||||
db, query_mock = _setup_db(monkeypatch, [])
|
||||
|
||||
await db.get_usage_data(limit=10, start_time_utc=start, end_time_utc=end)
|
||||
|
||||
query_text, *params = query_mock.await_args.args
|
||||
assert "dus.updated_at >= $1::timestamptz" in query_text
|
||||
assert "dus.updated_at <= $2::timestamptz" in query_text
|
||||
assert "LIMIT $3" in query_text
|
||||
assert params == [start, end, 10]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_usage_data_handles_missing_filters(monkeypatch: pytest.MonkeyPatch):
|
||||
"""When no filters provided the params should be None placeholders."""
|
||||
db, query_mock = _setup_db(monkeypatch, [])
|
||||
|
||||
await db.get_usage_data()
|
||||
|
||||
query_text, *params = query_mock.await_args.args
|
||||
assert "LIMIT $3" not in query_text
|
||||
assert params == [None, None]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_usage_data_rejects_invalid_limit(monkeypatch: pytest.MonkeyPatch):
|
||||
"""limit must coerce to int or raise ValueError before hitting the DB."""
|
||||
db, query_mock = _setup_db(monkeypatch, [])
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
await db.get_usage_data(limit="invalid")
|
||||
|
||||
assert query_mock.await_count == 0
|
||||
74
tests/test_litellm/integrations/focus/test_focus_database.py
Normal file
74
tests/test_litellm/integrations/focus/test_focus_database.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"""Tests for FocusLiteLLMDatabase query construction."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.integrations.focus.database import FocusLiteLLMDatabase
|
||||
|
||||
|
||||
def _setup_db(monkeypatch: pytest.MonkeyPatch, query_return):
|
||||
"""Create a database instance with a stubbed prisma client."""
|
||||
query_mock = AsyncMock(return_value=query_return)
|
||||
mock_client = SimpleNamespace(db=SimpleNamespace(query_raw=query_mock))
|
||||
db = FocusLiteLLMDatabase()
|
||||
monkeypatch.setattr(db, "_ensure_prisma_client", lambda: mock_client)
|
||||
return db, query_mock
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_parameterize_filters_and_limit(monkeypatch: pytest.MonkeyPatch):
|
||||
start = datetime(2024, 1, 1, tzinfo=timezone.utc)
|
||||
end = datetime(2024, 1, 2, tzinfo=timezone.utc)
|
||||
db, query_mock = _setup_db(monkeypatch, [])
|
||||
|
||||
await db.get_usage_data(limit=25, start_time_utc=start, end_time_utc=end)
|
||||
|
||||
query_text, *params = query_mock.await_args.args
|
||||
assert "dus.updated_at >= $1::timestamptz" in query_text
|
||||
assert "dus.updated_at <= $2::timestamptz" in query_text
|
||||
assert "LIMIT $3" in query_text
|
||||
assert params == [start, end, 25]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_execute_without_filters(monkeypatch: pytest.MonkeyPatch):
|
||||
row = {
|
||||
"id": 1,
|
||||
"user_id": "user",
|
||||
"date": datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||||
}
|
||||
db, query_mock = _setup_db(monkeypatch, [row])
|
||||
|
||||
result = await db.get_usage_data()
|
||||
|
||||
query_text, *params = query_mock.await_args.args
|
||||
assert "WHERE" not in query_text
|
||||
assert "LIMIT $" not in query_text
|
||||
assert params == []
|
||||
assert result.height == 1
|
||||
assert result["id"][0] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_accept_string_timestamps(monkeypatch: pytest.MonkeyPatch):
|
||||
db, query_mock = _setup_db(monkeypatch, [])
|
||||
|
||||
start = "2024-02-01T00:00:00+00:00"
|
||||
end = "2024-02-02T00:00:00+00:00"
|
||||
await db.get_usage_data(start_time_utc=start, end_time_utc=end)
|
||||
|
||||
_, *params = query_mock.await_args.args
|
||||
assert params == [start, end]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_reject_invalid_limit(monkeypatch: pytest.MonkeyPatch):
|
||||
db, query_mock = _setup_db(monkeypatch, [])
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
await db.get_usage_data(limit="invalid")
|
||||
|
||||
assert query_mock.await_count == 0
|
||||
100
tests/test_litellm/integrations/focus/test_s3_destination.py
Normal file
100
tests/test_litellm/integrations/focus/test_s3_destination.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"""Tests for FocusS3Destination behavior."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Dict
|
||||
|
||||
import pytest
|
||||
|
||||
import litellm.integrations.focus.destinations.s3_destination as s3_module
|
||||
from litellm.integrations.focus.destinations.base import FocusTimeWindow
|
||||
from litellm.integrations.focus.destinations.s3_destination import FocusS3Destination
|
||||
|
||||
|
||||
def _window(freq: str = "hourly", hour: int = 5) -> FocusTimeWindow:
|
||||
start = datetime(2024, 1, 2, hour, tzinfo=timezone.utc)
|
||||
end = start.replace(hour=hour + 1)
|
||||
return FocusTimeWindow(start_time=start, end_time=end, frequency=freq)
|
||||
|
||||
|
||||
def test_should_require_bucket_name():
|
||||
with pytest.raises(ValueError):
|
||||
FocusS3Destination(prefix="focus", config={})
|
||||
|
||||
|
||||
def test_should_build_hourly_object_key():
|
||||
dest = FocusS3Destination(prefix="exports/", config={"bucket_name": "bucket"})
|
||||
key = dest._build_object_key(
|
||||
time_window=_window(freq="hourly", hour=3), filename="data.snappy"
|
||||
)
|
||||
assert key == "exports/date=2024-01-02/hour=03/data.snappy"
|
||||
|
||||
|
||||
def test_should_build_daily_key_without_hour_segment():
|
||||
dest = FocusS3Destination(prefix="", config={"bucket_name": "bucket"})
|
||||
key = dest._build_object_key(
|
||||
time_window=_window(freq="daily", hour=0), filename="daily.parquet"
|
||||
)
|
||||
assert key == "date=2024-01-02/daily.parquet"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_dispatch_upload_via_thread(monkeypatch: pytest.MonkeyPatch):
|
||||
dest = FocusS3Destination(prefix="focus", config={"bucket_name": "bucket"})
|
||||
captured: Dict[str, Any] = {}
|
||||
|
||||
async def fake_to_thread(func, *args, **kwargs): # type: ignore[override]
|
||||
captured["func"] = func
|
||||
captured["args"] = args
|
||||
captured["kwargs"] = kwargs
|
||||
|
||||
monkeypatch.setattr(s3_module.asyncio, "to_thread", fake_to_thread)
|
||||
|
||||
window = _window(freq="hourly", hour=1)
|
||||
await dest.deliver(content=b"payload", time_window=window, filename="file.bin")
|
||||
|
||||
assert captured["func"] == dest._upload
|
||||
assert captured["args"][0] == b"payload"
|
||||
assert captured["args"][1].endswith("/file.bin")
|
||||
|
||||
|
||||
def test_should_upload_with_configured_client(monkeypatch: pytest.MonkeyPatch):
|
||||
config = {
|
||||
"bucket_name": "bucket",
|
||||
"region_name": "us-east-2",
|
||||
"endpoint_url": "http://localhost:4566",
|
||||
"aws_access_key_id": "key",
|
||||
"aws_secret_access_key": "secret",
|
||||
"aws_session_token": "token",
|
||||
}
|
||||
dest = FocusS3Destination(prefix="focus", config=config)
|
||||
captured: Dict[str, Any] = {}
|
||||
|
||||
def fake_client(service: str, **kwargs):
|
||||
assert service == "s3"
|
||||
captured["client_kwargs"] = kwargs
|
||||
|
||||
def put_object(**put_kwargs):
|
||||
captured["put_kwargs"] = put_kwargs
|
||||
|
||||
return SimpleNamespace(put_object=put_object)
|
||||
|
||||
monkeypatch.setattr(s3_module.boto3, "client", fake_client)
|
||||
|
||||
dest._upload(content=b"payload", object_key="path/file.bin")
|
||||
|
||||
assert captured["client_kwargs"] == {
|
||||
"region_name": "us-east-2",
|
||||
"endpoint_url": "http://localhost:4566",
|
||||
"aws_access_key_id": "key",
|
||||
"aws_secret_access_key": "secret",
|
||||
"aws_session_token": "token",
|
||||
}
|
||||
assert captured["put_kwargs"] == {
|
||||
"Bucket": "bucket",
|
||||
"Key": "path/file.bin",
|
||||
"Body": b"payload",
|
||||
"ContentType": "application/octet-stream",
|
||||
}
|
||||
|
|
@ -530,7 +530,7 @@ class TestPassthroughCallTypeHandling:
|
|||
)
|
||||
assert (
|
||||
ProxyBaseLLMRequestProcessing._get_pre_call_type(route_type="aembedding")
|
||||
== "embeddings"
|
||||
== "embedding"
|
||||
)
|
||||
assert (
|
||||
ProxyBaseLLMRequestProcessing._get_pre_call_type(route_type="aresponses")
|
||||
|
|
|
|||
211
tests/test_litellm/integrations/test_prometheus_cache_metrics.py
Normal file
211
tests/test_litellm/integrations/test_prometheus_cache_metrics.py
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
"""
|
||||
Unit tests for cache Prometheus metrics.
|
||||
|
||||
Run with: poetry run pytest tests/test_litellm/integrations/test_prometheus_cache_metrics.py -v
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from litellm.types.integrations.prometheus import UserAPIKeyLabelValues
|
||||
|
||||
|
||||
class TestPrometheusCacheMetrics:
|
||||
"""Tests for cache-related Prometheus metrics"""
|
||||
|
||||
@pytest.fixture
|
||||
def sample_enum_values(self):
|
||||
"""Create sample enum values for labels"""
|
||||
return UserAPIKeyLabelValues(
|
||||
end_user="test-end-user",
|
||||
hashed_api_key="test-key-hash",
|
||||
api_key_alias="test-key-alias",
|
||||
team="test-team",
|
||||
team_alias="test-team-alias",
|
||||
user="test-user",
|
||||
model="gpt-3.5-turbo",
|
||||
)
|
||||
|
||||
def test_cache_metrics_defined_in_types(self):
|
||||
"""Test that cache metrics are defined in DEFINED_PROMETHEUS_METRICS"""
|
||||
from litellm.types.integrations.prometheus import DEFINED_PROMETHEUS_METRICS
|
||||
from typing import get_args
|
||||
|
||||
defined_metrics = get_args(DEFINED_PROMETHEUS_METRICS)
|
||||
|
||||
assert "litellm_cache_hits_metric" in defined_metrics
|
||||
assert "litellm_cache_misses_metric" in defined_metrics
|
||||
assert "litellm_cached_tokens_metric" in defined_metrics
|
||||
|
||||
def test_cache_metric_labels_defined(self):
|
||||
"""Test that cache metric labels are properly defined"""
|
||||
from litellm.types.integrations.prometheus import PrometheusMetricLabels
|
||||
|
||||
# Verify labels are defined for each cache metric
|
||||
assert hasattr(PrometheusMetricLabels, "litellm_cache_hits_metric")
|
||||
assert hasattr(PrometheusMetricLabels, "litellm_cache_misses_metric")
|
||||
assert hasattr(PrometheusMetricLabels, "litellm_cached_tokens_metric")
|
||||
|
||||
# Verify labels include expected keys
|
||||
expected_labels = [
|
||||
"model",
|
||||
"hashed_api_key",
|
||||
"api_key_alias",
|
||||
"team",
|
||||
"team_alias",
|
||||
"end_user",
|
||||
"user",
|
||||
]
|
||||
for label in expected_labels:
|
||||
assert label in PrometheusMetricLabels.litellm_cache_hits_metric
|
||||
assert label in PrometheusMetricLabels.litellm_cache_misses_metric
|
||||
assert label in PrometheusMetricLabels.litellm_cached_tokens_metric
|
||||
|
||||
def test_increment_cache_metrics_on_cache_hit(self, sample_enum_values):
|
||||
"""Test that cache hit increments the correct metrics"""
|
||||
# Create mock for PrometheusLogger instance
|
||||
mock_logger = MagicMock()
|
||||
|
||||
# Import the method directly and bind it to our mock
|
||||
from litellm.integrations.prometheus import PrometheusLogger
|
||||
|
||||
# Create a mock standard logging payload with cache_hit=True
|
||||
standard_logging_payload = {
|
||||
"cache_hit": True,
|
||||
"total_tokens": 100,
|
||||
"prompt_tokens": 50,
|
||||
"completion_tokens": 50,
|
||||
"model_group": "openai",
|
||||
"request_tags": [],
|
||||
}
|
||||
|
||||
# Create mock metrics
|
||||
mock_logger.litellm_cache_hits_metric = MagicMock()
|
||||
mock_logger.litellm_cache_misses_metric = MagicMock()
|
||||
mock_logger.litellm_cached_tokens_metric = MagicMock()
|
||||
mock_logger.get_labels_for_metric = MagicMock(
|
||||
return_value=[
|
||||
"model",
|
||||
"hashed_api_key",
|
||||
"api_key_alias",
|
||||
"team",
|
||||
"team_alias",
|
||||
"end_user",
|
||||
"user",
|
||||
]
|
||||
)
|
||||
|
||||
# Call the method using unbound method approach
|
||||
PrometheusLogger._increment_cache_metrics(
|
||||
mock_logger,
|
||||
standard_logging_payload=standard_logging_payload,
|
||||
enum_values=sample_enum_values,
|
||||
)
|
||||
|
||||
# Verify cache hits metric was incremented
|
||||
mock_logger.litellm_cache_hits_metric.labels.assert_called()
|
||||
mock_logger.litellm_cache_hits_metric.labels().inc.assert_called_once()
|
||||
|
||||
# Verify cached tokens metric was incremented with total_tokens
|
||||
mock_logger.litellm_cached_tokens_metric.labels.assert_called()
|
||||
mock_logger.litellm_cached_tokens_metric.labels().inc.assert_called_once_with(
|
||||
100
|
||||
)
|
||||
|
||||
# Verify cache misses metric was NOT called
|
||||
mock_logger.litellm_cache_misses_metric.labels.assert_not_called()
|
||||
|
||||
def test_increment_cache_metrics_on_cache_miss(self, sample_enum_values):
|
||||
"""Test that cache miss increments the correct metrics"""
|
||||
# Create mock for PrometheusLogger instance
|
||||
mock_logger = MagicMock()
|
||||
|
||||
from litellm.integrations.prometheus import PrometheusLogger
|
||||
|
||||
# Create a mock standard logging payload with cache_hit=False
|
||||
standard_logging_payload = {
|
||||
"cache_hit": False,
|
||||
"total_tokens": 100,
|
||||
"prompt_tokens": 50,
|
||||
"completion_tokens": 50,
|
||||
"model_group": "openai",
|
||||
"request_tags": [],
|
||||
}
|
||||
|
||||
# Create mock metrics
|
||||
mock_logger.litellm_cache_hits_metric = MagicMock()
|
||||
mock_logger.litellm_cache_misses_metric = MagicMock()
|
||||
mock_logger.litellm_cached_tokens_metric = MagicMock()
|
||||
mock_logger.get_labels_for_metric = MagicMock(
|
||||
return_value=[
|
||||
"model",
|
||||
"hashed_api_key",
|
||||
"api_key_alias",
|
||||
"team",
|
||||
"team_alias",
|
||||
"end_user",
|
||||
"user",
|
||||
]
|
||||
)
|
||||
|
||||
# Call the method
|
||||
PrometheusLogger._increment_cache_metrics(
|
||||
mock_logger,
|
||||
standard_logging_payload=standard_logging_payload,
|
||||
enum_values=sample_enum_values,
|
||||
)
|
||||
|
||||
# Verify cache misses metric was incremented
|
||||
mock_logger.litellm_cache_misses_metric.labels.assert_called()
|
||||
mock_logger.litellm_cache_misses_metric.labels().inc.assert_called_once()
|
||||
|
||||
# Verify cache hits and cached tokens metrics were NOT called
|
||||
mock_logger.litellm_cache_hits_metric.labels.assert_not_called()
|
||||
mock_logger.litellm_cached_tokens_metric.labels.assert_not_called()
|
||||
|
||||
def test_increment_cache_metrics_when_cache_hit_is_none(self, sample_enum_values):
|
||||
"""Test that no metrics are incremented when cache_hit is None"""
|
||||
# Create mock for PrometheusLogger instance
|
||||
mock_logger = MagicMock()
|
||||
|
||||
from litellm.integrations.prometheus import PrometheusLogger
|
||||
|
||||
# Create a mock standard logging payload with cache_hit=None
|
||||
standard_logging_payload = {
|
||||
"cache_hit": None,
|
||||
"total_tokens": 100,
|
||||
"prompt_tokens": 50,
|
||||
"completion_tokens": 50,
|
||||
"model_group": "openai",
|
||||
"request_tags": [],
|
||||
}
|
||||
|
||||
# Create mock metrics
|
||||
mock_logger.litellm_cache_hits_metric = MagicMock()
|
||||
mock_logger.litellm_cache_misses_metric = MagicMock()
|
||||
mock_logger.litellm_cached_tokens_metric = MagicMock()
|
||||
mock_logger.get_labels_for_metric = MagicMock(
|
||||
return_value=[
|
||||
"model",
|
||||
"hashed_api_key",
|
||||
"api_key_alias",
|
||||
"team",
|
||||
"team_alias",
|
||||
"end_user",
|
||||
"user",
|
||||
]
|
||||
)
|
||||
|
||||
# Call the method
|
||||
PrometheusLogger._increment_cache_metrics(
|
||||
mock_logger,
|
||||
standard_logging_payload=standard_logging_payload,
|
||||
enum_values=sample_enum_values,
|
||||
)
|
||||
|
||||
# Verify NO metrics were called
|
||||
mock_logger.litellm_cache_hits_metric.labels.assert_not_called()
|
||||
mock_logger.litellm_cache_misses_metric.labels.assert_not_called()
|
||||
mock_logger.litellm_cached_tokens_metric.labels.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
|
|
@ -0,0 +1,424 @@
|
|||
"""
|
||||
Unit tests for prometheus queue time and guardrail metrics
|
||||
"""
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from prometheus_client import REGISTRY
|
||||
|
||||
from litellm.integrations.prometheus import PrometheusLogger
|
||||
from litellm.types.integrations.prometheus import UserAPIKeyLabelValues
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def cleanup_prometheus_registry():
|
||||
"""Clean up prometheus registry between tests"""
|
||||
# Clear the registry before each test
|
||||
collectors = list(REGISTRY._collector_to_names.keys())
|
||||
for collector in collectors:
|
||||
REGISTRY.unregister(collector)
|
||||
yield
|
||||
# Clean up after test
|
||||
collectors = list(REGISTRY._collector_to_names.keys())
|
||||
for collector in collectors:
|
||||
REGISTRY.unregister(collector)
|
||||
|
||||
|
||||
class TestPrometheusQueueTimeMetric:
|
||||
"""Test request queue time metric recording"""
|
||||
|
||||
def test_queue_time_metric_recorded_in_set_latency_metrics(self):
|
||||
"""Test that queue time metric is recorded when queue_time_seconds is present in metadata"""
|
||||
# Arrange
|
||||
prometheus_logger = PrometheusLogger()
|
||||
|
||||
# Mock the metric
|
||||
mock_metric = MagicMock()
|
||||
mock_labeled_metric = MagicMock()
|
||||
mock_metric.labels.return_value = mock_labeled_metric
|
||||
prometheus_logger.litellm_request_queue_time_metric = mock_metric
|
||||
|
||||
# Create mock kwargs with queue_time_seconds in metadata
|
||||
queue_time_seconds = 0.5
|
||||
|
||||
kwargs = {
|
||||
"litellm_params": {"metadata": {"queue_time_seconds": queue_time_seconds}},
|
||||
"model": "gpt-3.5-turbo",
|
||||
"start_time": datetime.now(),
|
||||
"end_time": datetime.now(),
|
||||
}
|
||||
|
||||
enum_values = UserAPIKeyLabelValues(
|
||||
end_user=None,
|
||||
hashed_api_key="test-key",
|
||||
api_key_alias="test-alias",
|
||||
requested_model="gpt-3.5-turbo",
|
||||
model_group="gpt-3.5-turbo",
|
||||
team=None,
|
||||
team_alias=None,
|
||||
user=None,
|
||||
user_email=None,
|
||||
status_code="200",
|
||||
model="gpt-3.5-turbo",
|
||||
litellm_model_name="gpt-3.5-turbo",
|
||||
tags=[],
|
||||
model_id="gpt-3.5-turbo",
|
||||
api_base="https://api.openai.com",
|
||||
api_provider="openai",
|
||||
exception_status=None,
|
||||
exception_class=None,
|
||||
custom_metadata_labels={},
|
||||
route=None,
|
||||
)
|
||||
|
||||
# Act
|
||||
prometheus_logger._set_latency_metrics(
|
||||
kwargs=kwargs,
|
||||
model="gpt-3.5-turbo",
|
||||
user_api_key="test-key",
|
||||
user_api_key_alias="test-alias",
|
||||
user_api_team=None,
|
||||
user_api_team_alias=None,
|
||||
enum_values=enum_values,
|
||||
)
|
||||
|
||||
# Assert - queue time metric should be called
|
||||
mock_metric.labels.assert_called()
|
||||
# Check that observe was called on the queue time metric
|
||||
assert mock_labeled_metric.observe.called
|
||||
# Verify the observed value
|
||||
observed_value = None
|
||||
for call in mock_labeled_metric.observe.call_args_list:
|
||||
if len(call[0]) > 0:
|
||||
observed_value = call[0][0]
|
||||
if observed_value == queue_time_seconds:
|
||||
break
|
||||
assert observed_value == queue_time_seconds
|
||||
assert observed_value >= 0
|
||||
|
||||
def test_queue_time_metric_not_recorded_when_missing(self):
|
||||
"""Test that queue time metric is not recorded when queue_time_seconds is missing"""
|
||||
# Arrange
|
||||
prometheus_logger = PrometheusLogger()
|
||||
|
||||
# Mock the metric
|
||||
mock_metric = MagicMock()
|
||||
mock_labeled_metric = MagicMock()
|
||||
mock_metric.labels.return_value = mock_labeled_metric
|
||||
prometheus_logger.litellm_request_queue_time_metric = mock_metric
|
||||
|
||||
# Create mock kwargs without queue_time_seconds
|
||||
kwargs = {
|
||||
"litellm_params": {"metadata": {}},
|
||||
"model": "gpt-3.5-turbo",
|
||||
"start_time": datetime.now(),
|
||||
"end_time": datetime.now(),
|
||||
}
|
||||
|
||||
enum_values = UserAPIKeyLabelValues(
|
||||
end_user=None,
|
||||
hashed_api_key="test-key",
|
||||
api_key_alias="test-alias",
|
||||
requested_model="gpt-3.5-turbo",
|
||||
model_group="gpt-3.5-turbo",
|
||||
team=None,
|
||||
team_alias=None,
|
||||
user=None,
|
||||
user_email=None,
|
||||
status_code="200",
|
||||
model="gpt-3.5-turbo",
|
||||
litellm_model_name="gpt-3.5-turbo",
|
||||
tags=[],
|
||||
model_id="gpt-3.5-turbo",
|
||||
api_base="https://api.openai.com",
|
||||
api_provider="openai",
|
||||
exception_status=None,
|
||||
exception_class=None,
|
||||
custom_metadata_labels={},
|
||||
route=None,
|
||||
)
|
||||
|
||||
# Act
|
||||
prometheus_logger._set_latency_metrics(
|
||||
kwargs=kwargs,
|
||||
model="gpt-3.5-turbo",
|
||||
user_api_key="test-key",
|
||||
user_api_key_alias="test-alias",
|
||||
user_api_team=None,
|
||||
user_api_team_alias=None,
|
||||
enum_values=enum_values,
|
||||
)
|
||||
|
||||
# Assert - queue time metric should not be called (queue_time_seconds is None)
|
||||
# We check that observe was not called with queue_time_seconds
|
||||
queue_time_called = False
|
||||
for call in mock_labeled_metric.observe.call_args_list:
|
||||
if len(call[0]) > 0 and call[0][0] == 0.5: # Our test queue time value
|
||||
queue_time_called = True
|
||||
break
|
||||
assert (
|
||||
not queue_time_called
|
||||
), "Queue time metric should not be recorded when queue_time_seconds is missing"
|
||||
|
||||
def test_queue_time_metric_not_recorded_when_negative(self):
|
||||
"""Test that queue time metric is not recorded when queue_time_seconds is negative"""
|
||||
# Arrange
|
||||
prometheus_logger = PrometheusLogger()
|
||||
|
||||
# Mock the metric
|
||||
mock_metric = MagicMock()
|
||||
mock_labeled_metric = MagicMock()
|
||||
mock_metric.labels.return_value = mock_labeled_metric
|
||||
prometheus_logger.litellm_request_queue_time_metric = mock_metric
|
||||
|
||||
# Create mock kwargs with negative queue_time_seconds
|
||||
kwargs = {
|
||||
"litellm_params": {
|
||||
"metadata": {"queue_time_seconds": -0.1} # Negative value
|
||||
},
|
||||
"model": "gpt-3.5-turbo",
|
||||
"start_time": datetime.now(),
|
||||
"end_time": datetime.now(),
|
||||
}
|
||||
|
||||
enum_values = UserAPIKeyLabelValues(
|
||||
end_user=None,
|
||||
hashed_api_key="test-key",
|
||||
api_key_alias="test-alias",
|
||||
requested_model="gpt-3.5-turbo",
|
||||
model_group="gpt-3.5-turbo",
|
||||
team=None,
|
||||
team_alias=None,
|
||||
user=None,
|
||||
user_email=None,
|
||||
status_code="200",
|
||||
model="gpt-3.5-turbo",
|
||||
litellm_model_name="gpt-3.5-turbo",
|
||||
tags=[],
|
||||
model_id="gpt-3.5-turbo",
|
||||
api_base="https://api.openai.com",
|
||||
api_provider="openai",
|
||||
exception_status=None,
|
||||
exception_class=None,
|
||||
custom_metadata_labels={},
|
||||
route=None,
|
||||
)
|
||||
|
||||
# Act
|
||||
prometheus_logger._set_latency_metrics(
|
||||
kwargs=kwargs,
|
||||
model="gpt-3.5-turbo",
|
||||
user_api_key="test-key",
|
||||
user_api_key_alias="test-alias",
|
||||
user_api_team=None,
|
||||
user_api_team_alias=None,
|
||||
enum_values=enum_values,
|
||||
)
|
||||
|
||||
# Assert - queue time metric should not be called for negative values
|
||||
# We check that observe was not called with the negative value
|
||||
negative_value_called = False
|
||||
for call in mock_labeled_metric.observe.call_args_list:
|
||||
if len(call[0]) > 0 and call[0][0] == -0.1:
|
||||
negative_value_called = True
|
||||
break
|
||||
assert (
|
||||
not negative_value_called
|
||||
), "Queue time metric should not be recorded for negative values"
|
||||
|
||||
|
||||
class TestPrometheusGuardrailMetrics:
|
||||
"""Test guardrail metrics recording"""
|
||||
|
||||
def test_record_guardrail_metrics_success(self):
|
||||
"""Test recording guardrail metrics for successful execution"""
|
||||
# Arrange
|
||||
prometheus_logger = PrometheusLogger()
|
||||
|
||||
# Mock metrics
|
||||
mock_latency_metric = MagicMock()
|
||||
mock_requests_metric = MagicMock()
|
||||
mock_errors_metric = MagicMock()
|
||||
|
||||
prometheus_logger.litellm_guardrail_latency_metric = mock_latency_metric
|
||||
prometheus_logger.litellm_guardrail_requests_total = mock_requests_metric
|
||||
prometheus_logger.litellm_guardrail_errors_total = mock_errors_metric
|
||||
|
||||
guardrail_name = "test_guardrail"
|
||||
latency_seconds = 0.15
|
||||
status = "success"
|
||||
error_type = None
|
||||
hook_type = "pre_call"
|
||||
|
||||
# Act
|
||||
prometheus_logger._record_guardrail_metrics(
|
||||
guardrail_name=guardrail_name,
|
||||
latency_seconds=latency_seconds,
|
||||
status=status,
|
||||
error_type=error_type,
|
||||
hook_type=hook_type,
|
||||
)
|
||||
|
||||
# Assert - latency metric should be recorded
|
||||
mock_latency_metric.labels.assert_called_once_with(
|
||||
guardrail_name=guardrail_name,
|
||||
status=status,
|
||||
error_type="none",
|
||||
hook_type=hook_type,
|
||||
)
|
||||
mock_latency_metric.labels.return_value.observe.assert_called_once_with(
|
||||
latency_seconds
|
||||
)
|
||||
|
||||
# Assert - requests metric should be incremented
|
||||
mock_requests_metric.labels.assert_called_once_with(
|
||||
guardrail_name=guardrail_name,
|
||||
status=status,
|
||||
hook_type=hook_type,
|
||||
)
|
||||
mock_requests_metric.labels.return_value.inc.assert_called_once()
|
||||
|
||||
# Assert - errors metric should NOT be called for success
|
||||
mock_errors_metric.labels.assert_not_called()
|
||||
|
||||
def test_record_guardrail_metrics_error(self):
|
||||
"""Test recording guardrail metrics for failed execution"""
|
||||
# Arrange
|
||||
prometheus_logger = PrometheusLogger()
|
||||
|
||||
# Mock metrics
|
||||
mock_latency_metric = MagicMock()
|
||||
mock_requests_metric = MagicMock()
|
||||
mock_errors_metric = MagicMock()
|
||||
|
||||
prometheus_logger.litellm_guardrail_latency_metric = mock_latency_metric
|
||||
prometheus_logger.litellm_guardrail_requests_total = mock_requests_metric
|
||||
prometheus_logger.litellm_guardrail_errors_total = mock_errors_metric
|
||||
|
||||
guardrail_name = "test_guardrail"
|
||||
latency_seconds = 0.2
|
||||
status = "error"
|
||||
error_type = "ValueError"
|
||||
hook_type = "pre_call"
|
||||
|
||||
# Act
|
||||
prometheus_logger._record_guardrail_metrics(
|
||||
guardrail_name=guardrail_name,
|
||||
latency_seconds=latency_seconds,
|
||||
status=status,
|
||||
error_type=error_type,
|
||||
hook_type=hook_type,
|
||||
)
|
||||
|
||||
# Assert - latency metric should be recorded
|
||||
mock_latency_metric.labels.assert_called_once_with(
|
||||
guardrail_name=guardrail_name,
|
||||
status=status,
|
||||
error_type=error_type,
|
||||
hook_type=hook_type,
|
||||
)
|
||||
mock_latency_metric.labels.return_value.observe.assert_called_once_with(
|
||||
latency_seconds
|
||||
)
|
||||
|
||||
# Assert - requests metric should be incremented
|
||||
mock_requests_metric.labels.assert_called_once_with(
|
||||
guardrail_name=guardrail_name,
|
||||
status=status,
|
||||
hook_type=hook_type,
|
||||
)
|
||||
mock_requests_metric.labels.return_value.inc.assert_called_once()
|
||||
|
||||
# Assert - errors metric should be incremented
|
||||
mock_errors_metric.labels.assert_called_once_with(
|
||||
guardrail_name=guardrail_name,
|
||||
error_type=error_type,
|
||||
hook_type=hook_type,
|
||||
)
|
||||
mock_errors_metric.labels.return_value.inc.assert_called_once()
|
||||
|
||||
def test_record_guardrail_metrics_during_call_hook(self):
|
||||
"""Test recording guardrail metrics for during_call hook"""
|
||||
# Arrange
|
||||
prometheus_logger = PrometheusLogger()
|
||||
|
||||
# Mock metrics
|
||||
mock_latency_metric = MagicMock()
|
||||
mock_requests_metric = MagicMock()
|
||||
|
||||
prometheus_logger.litellm_guardrail_latency_metric = mock_latency_metric
|
||||
prometheus_logger.litellm_guardrail_requests_total = mock_requests_metric
|
||||
|
||||
guardrail_name = "moderation_guardrail"
|
||||
latency_seconds = 0.1
|
||||
status = "success"
|
||||
hook_type = "during_call"
|
||||
|
||||
# Act
|
||||
prometheus_logger._record_guardrail_metrics(
|
||||
guardrail_name=guardrail_name,
|
||||
latency_seconds=latency_seconds,
|
||||
status=status,
|
||||
error_type=None,
|
||||
hook_type=hook_type,
|
||||
)
|
||||
|
||||
# Assert - hook_type should be "during_call"
|
||||
mock_latency_metric.labels.assert_called_once()
|
||||
call_kwargs = mock_latency_metric.labels.call_args[1]
|
||||
assert call_kwargs["hook_type"] == "during_call"
|
||||
|
||||
def test_record_guardrail_metrics_handles_exception(self):
|
||||
"""Test that _record_guardrail_metrics handles exceptions gracefully"""
|
||||
# Arrange
|
||||
prometheus_logger = PrometheusLogger()
|
||||
|
||||
# Mock metric to raise exception
|
||||
mock_metric = MagicMock()
|
||||
mock_metric.labels.side_effect = Exception("Test error")
|
||||
prometheus_logger.litellm_guardrail_latency_metric = mock_metric
|
||||
prometheus_logger.litellm_guardrail_requests_total = MagicMock()
|
||||
|
||||
# Act & Assert - should not raise exception
|
||||
try:
|
||||
prometheus_logger._record_guardrail_metrics(
|
||||
guardrail_name="test",
|
||||
latency_seconds=0.1,
|
||||
status="success",
|
||||
error_type=None,
|
||||
hook_type="pre_call",
|
||||
)
|
||||
except Exception:
|
||||
pytest.fail("_record_guardrail_metrics should handle exceptions gracefully")
|
||||
|
||||
def test_record_guardrail_metrics_with_guardrail_name_attribute(self):
|
||||
"""Test that guardrail name is extracted from guardrail_name attribute if available"""
|
||||
# Arrange
|
||||
prometheus_logger = PrometheusLogger()
|
||||
|
||||
# Mock metrics
|
||||
mock_latency_metric = MagicMock()
|
||||
mock_requests_metric = MagicMock()
|
||||
|
||||
prometheus_logger.litellm_guardrail_latency_metric = mock_latency_metric
|
||||
prometheus_logger.litellm_guardrail_requests_total = mock_requests_metric
|
||||
|
||||
guardrail_name = "custom_guardrail_name"
|
||||
latency_seconds = 0.1
|
||||
status = "success"
|
||||
hook_type = "pre_call"
|
||||
|
||||
# Act
|
||||
prometheus_logger._record_guardrail_metrics(
|
||||
guardrail_name=guardrail_name,
|
||||
latency_seconds=latency_seconds,
|
||||
status=status,
|
||||
error_type=None,
|
||||
hook_type=hook_type,
|
||||
)
|
||||
|
||||
# Assert - guardrail_name should be used
|
||||
mock_latency_metric.labels.assert_called_once()
|
||||
call_kwargs = mock_latency_metric.labels.call_args[1]
|
||||
assert call_kwargs["guardrail_name"] == guardrail_name
|
||||
|
|
@ -620,26 +620,26 @@ def test_bedrock_tools_unpack_defs():
|
|||
|
||||
def test_bedrock_image_processor_content_type_fallback_url_extension():
|
||||
"""
|
||||
Test that _post_call_image_processing falls back to URL extension
|
||||
Test that _post_call_image_processing falls back to URL extension
|
||||
when content-type is binary/octet-stream or application/octet-stream
|
||||
"""
|
||||
import base64
|
||||
|
||||
|
||||
# Create mock response with binary/octet-stream content-type
|
||||
mock_response = MagicMock()
|
||||
mock_response.headers.get.return_value = "binary/octet-stream"
|
||||
|
||||
|
||||
# Create a simple PNG header (magic bytes)
|
||||
png_header = b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a"
|
||||
png_content = png_header + b"\x00" * 100 # Add some padding
|
||||
mock_response.content = png_content
|
||||
|
||||
|
||||
# Test with .png URL
|
||||
image_url = "https://example.com/test-image.png"
|
||||
base64_bytes, content_type = BedrockImageProcessor._post_call_image_processing(
|
||||
mock_response, image_url
|
||||
)
|
||||
|
||||
|
||||
assert content_type == "image/png"
|
||||
assert base64_bytes == base64.b64encode(png_content).decode("utf-8")
|
||||
|
||||
|
|
@ -650,22 +650,22 @@ def test_bedrock_image_processor_content_type_fallback_binary_detection():
|
|||
when content-type is missing and URL extension is not recognized
|
||||
"""
|
||||
import base64
|
||||
|
||||
|
||||
# Create mock response with no content-type
|
||||
mock_response = MagicMock()
|
||||
mock_response.headers.get.return_value = None
|
||||
|
||||
|
||||
# Create a JPEG header (magic bytes)
|
||||
jpeg_header = b"\xff\xd8\xff"
|
||||
jpeg_content = jpeg_header + b"\x00" * 100 # Add some padding
|
||||
mock_response.content = jpeg_content
|
||||
|
||||
|
||||
# Test with URL without extension
|
||||
image_url = "https://example.com/test-image-without-extension"
|
||||
base64_bytes, content_type = BedrockImageProcessor._post_call_image_processing(
|
||||
mock_response, image_url
|
||||
)
|
||||
|
||||
|
||||
assert content_type == "image/jpeg"
|
||||
assert base64_bytes == base64.b64encode(jpeg_content).decode("utf-8")
|
||||
|
||||
|
|
@ -675,22 +675,22 @@ def test_bedrock_image_processor_content_type_fallback_application_octet_stream(
|
|||
Test that _post_call_image_processing handles application/octet-stream correctly
|
||||
"""
|
||||
import base64
|
||||
|
||||
|
||||
# Create mock response with application/octet-stream content-type
|
||||
mock_response = MagicMock()
|
||||
mock_response.headers.get.return_value = "application/octet-stream"
|
||||
|
||||
|
||||
# Create a GIF header (magic bytes)
|
||||
gif_header = b"GIF8" + b"\x00" + b"a"
|
||||
gif_content = gif_header + b"\x00" * 100 # Add some padding
|
||||
mock_response.content = gif_content
|
||||
|
||||
|
||||
# Test with .gif URL
|
||||
image_url = "https://s3.amazonaws.com/bucket/image.gif"
|
||||
base64_bytes, content_type = BedrockImageProcessor._post_call_image_processing(
|
||||
mock_response, image_url
|
||||
)
|
||||
|
||||
|
||||
assert content_type == "image/gif"
|
||||
assert base64_bytes == base64.b64encode(gif_content).decode("utf-8")
|
||||
|
||||
|
|
@ -700,22 +700,22 @@ def test_bedrock_image_processor_content_type_with_query_params():
|
|||
Test that _post_call_image_processing correctly extracts extension from URL with query parameters
|
||||
"""
|
||||
import base64
|
||||
|
||||
|
||||
# Create mock response with binary/octet-stream content-type
|
||||
mock_response = MagicMock()
|
||||
mock_response.headers.get.return_value = "binary/octet-stream"
|
||||
|
||||
|
||||
# Create a WebP header (magic bytes)
|
||||
webp_header = b"RIFF" + b"\x00\x00\x00\x00" + b"WEBP"
|
||||
webp_content = webp_header + b"\x00" * 100 # Add some padding
|
||||
mock_response.content = webp_content
|
||||
|
||||
|
||||
# Test with URL containing query parameters (common in S3 signed URLs)
|
||||
image_url = "https://s3.amazonaws.com/bucket/image.webp?AWSAccessKeyId=123&Expires=456&Signature=789"
|
||||
base64_bytes, content_type = BedrockImageProcessor._post_call_image_processing(
|
||||
mock_response, image_url
|
||||
)
|
||||
|
||||
|
||||
assert content_type == "image/webp"
|
||||
assert base64_bytes == base64.b64encode(webp_content).decode("utf-8")
|
||||
|
||||
|
|
@ -725,21 +725,21 @@ def test_bedrock_image_processor_content_type_normal_header():
|
|||
Test that _post_call_image_processing works normally when content-type is correctly set
|
||||
"""
|
||||
import base64
|
||||
|
||||
|
||||
# Create mock response with correct content-type
|
||||
mock_response = MagicMock()
|
||||
mock_response.headers.get.return_value = "image/png"
|
||||
|
||||
|
||||
# Create a PNG header
|
||||
png_header = b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a"
|
||||
png_content = png_header + b"\x00" * 100
|
||||
mock_response.content = png_content
|
||||
|
||||
|
||||
image_url = "https://example.com/test-image.png"
|
||||
base64_bytes, content_type = BedrockImageProcessor._post_call_image_processing(
|
||||
mock_response, image_url
|
||||
)
|
||||
|
||||
|
||||
assert content_type == "image/png"
|
||||
assert base64_bytes == base64.b64encode(png_content).decode("utf-8")
|
||||
|
||||
|
|
@ -751,16 +751,16 @@ def test_bedrock_image_processor_content_type_fallback_failure():
|
|||
# Create mock response with binary/octet-stream content-type
|
||||
mock_response = MagicMock()
|
||||
mock_response.headers.get.return_value = "binary/octet-stream"
|
||||
|
||||
|
||||
# Create content with unrecognizable image format
|
||||
mock_response.content = b"\x00" * 100
|
||||
|
||||
|
||||
# Test with URL without recognizable extension
|
||||
image_url = "https://example.com/unknown-file"
|
||||
|
||||
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
BedrockImageProcessor._post_call_image_processing(mock_response, image_url)
|
||||
|
||||
|
||||
assert "Unable to determine content type" in str(excinfo.value)
|
||||
|
||||
|
||||
|
|
@ -771,18 +771,18 @@ def test_bedrock_image_processor_content_type_jpeg_variants():
|
|||
# Create mock response with binary/octet-stream
|
||||
mock_response = MagicMock()
|
||||
mock_response.headers.get.return_value = "binary/octet-stream"
|
||||
|
||||
|
||||
jpeg_header = b"\xff\xd8\xff"
|
||||
jpeg_content = jpeg_header + b"\x00" * 100
|
||||
mock_response.content = jpeg_content
|
||||
|
||||
|
||||
# Test with .jpg extension
|
||||
image_url_jpg = "https://example.com/photo.jpg"
|
||||
_, content_type_jpg = BedrockImageProcessor._post_call_image_processing(
|
||||
mock_response, image_url_jpg
|
||||
)
|
||||
assert content_type_jpg == "image/jpeg"
|
||||
|
||||
|
||||
# Test with .jpeg extension
|
||||
image_url_jpeg = "https://example.com/photo.jpeg"
|
||||
_, content_type_jpeg = BedrockImageProcessor._post_call_image_processing(
|
||||
|
|
@ -797,22 +797,22 @@ def test_bedrock_image_processor_content_type_pdf_document():
|
|||
when content-type is binary/octet-stream
|
||||
"""
|
||||
import base64
|
||||
|
||||
|
||||
# Create mock response with binary/octet-stream content-type
|
||||
mock_response = MagicMock()
|
||||
mock_response.headers.get.return_value = "binary/octet-stream"
|
||||
|
||||
|
||||
# Create a PDF header (magic bytes: %PDF)
|
||||
pdf_header = b"%PDF-1.4"
|
||||
pdf_content = pdf_header + b"\x00" * 100
|
||||
mock_response.content = pdf_content
|
||||
|
||||
|
||||
# Test with .pdf URL
|
||||
pdf_url = "https://s3.amazonaws.com/bucket/document.pdf"
|
||||
base64_bytes, content_type = BedrockImageProcessor._post_call_image_processing(
|
||||
mock_response, pdf_url
|
||||
)
|
||||
|
||||
|
||||
assert content_type == "application/pdf"
|
||||
assert base64_bytes == base64.b64encode(pdf_content).decode("utf-8")
|
||||
|
||||
|
|
@ -822,12 +822,12 @@ def test_bedrock_image_processor_content_type_document_formats():
|
|||
Test that _post_call_image_processing handles various document formats
|
||||
"""
|
||||
import base64
|
||||
|
||||
|
||||
# Create mock response
|
||||
mock_response = MagicMock()
|
||||
mock_response.headers.get.return_value = "application/octet-stream"
|
||||
mock_response.content = b"\x00" * 100
|
||||
|
||||
|
||||
# Test various document formats
|
||||
test_cases = [
|
||||
("https://example.com/doc.pdf", "application/pdf"),
|
||||
|
|
@ -837,7 +837,7 @@ def test_bedrock_image_processor_content_type_document_formats():
|
|||
("https://example.com/page.html", "text/html"),
|
||||
("https://example.com/readme.txt", "text/plain"),
|
||||
]
|
||||
|
||||
|
||||
for url, expected_mime in test_cases:
|
||||
_, content_type = BedrockImageProcessor._post_call_image_processing(
|
||||
mock_response, url
|
||||
|
|
@ -850,21 +850,21 @@ def test_bedrock_image_processor_content_type_s3_pdf_with_query():
|
|||
Test that _post_call_image_processing handles S3 PDF with query parameters
|
||||
"""
|
||||
import base64
|
||||
|
||||
|
||||
# Create mock response
|
||||
mock_response = MagicMock()
|
||||
mock_response.headers.get.return_value = "binary/octet-stream"
|
||||
|
||||
|
||||
pdf_content = b"%PDF-1.4" + b"\x00" * 100
|
||||
mock_response.content = pdf_content
|
||||
|
||||
|
||||
# S3 signed URL with query parameters
|
||||
s3_url = "https://my-bucket.s3.us-east-1.amazonaws.com/documents/report.pdf?AWSAccessKeyId=AKIAIOSFODNN7EXAMPLE&Expires=1234567890&Signature=abcdef123456"
|
||||
|
||||
|
||||
base64_bytes, content_type = BedrockImageProcessor._post_call_image_processing(
|
||||
mock_response, s3_url
|
||||
)
|
||||
|
||||
|
||||
assert content_type == "application/pdf"
|
||||
assert base64_bytes == base64.b64encode(pdf_content).decode("utf-8")
|
||||
|
||||
|
|
@ -1139,6 +1139,170 @@ def test_bedrock_create_bedrock_block_different_document_formats():
|
|||
assert block["document"]["format"] == format_type
|
||||
|
||||
|
||||
def test_convert_to_anthropic_tool_result_image_with_cache_control():
|
||||
"""
|
||||
Test that cache_control is properly applied to image content in tool results.
|
||||
This tests the functionality added in the uncommitted changes where
|
||||
add_cache_control_to_content is called for image_url content types.
|
||||
"""
|
||||
from litellm.litellm_core_utils.prompt_templates.factory import (
|
||||
convert_to_anthropic_tool_result,
|
||||
)
|
||||
|
||||
# Test with base64 image data URI
|
||||
message = {
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_test_123",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Here is the image you requested:",
|
||||
},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQ",
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
result = convert_to_anthropic_tool_result(message)
|
||||
|
||||
# Verify the result structure
|
||||
assert result["type"] == "tool_result"
|
||||
assert result["tool_use_id"] == "call_test_123"
|
||||
assert isinstance(result["content"], list)
|
||||
assert len(result["content"]) == 2
|
||||
|
||||
# Verify text content
|
||||
assert result["content"][0]["type"] == "text"
|
||||
assert result["content"][0]["text"] == "Here is the image you requested:"
|
||||
|
||||
# Verify image content with cache_control
|
||||
assert result["content"][1]["type"] == "image"
|
||||
assert result["content"][1]["source"]["type"] == "base64"
|
||||
assert result["content"][1]["source"]["media_type"] == "image/jpeg"
|
||||
assert "cache_control" in result["content"][1]
|
||||
assert result["content"][1]["cache_control"]["type"] == "ephemeral"
|
||||
|
||||
|
||||
def test_convert_to_anthropic_tool_result_image_without_cache_control():
|
||||
"""
|
||||
Test that images without cache_control in tool results work correctly.
|
||||
"""
|
||||
from litellm.litellm_core_utils.prompt_templates.factory import (
|
||||
convert_to_anthropic_tool_result,
|
||||
)
|
||||
|
||||
message = {
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_test_456",
|
||||
"content": [
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
result = convert_to_anthropic_tool_result(message)
|
||||
|
||||
# Verify the result structure
|
||||
assert result["type"] == "tool_result"
|
||||
assert result["tool_use_id"] == "call_test_456"
|
||||
assert isinstance(result["content"], list)
|
||||
assert len(result["content"]) == 1
|
||||
|
||||
# Verify image content without cache_control (cache_control will be None if not set)
|
||||
assert result["content"][0]["type"] == "image"
|
||||
assert result["content"][0]["source"]["type"] == "base64"
|
||||
assert result["content"][0]["source"]["media_type"] == "image/png"
|
||||
assert result["content"][0].get("cache_control") is None
|
||||
|
||||
|
||||
def test_convert_to_anthropic_tool_result_mixed_content_with_cache_control():
|
||||
"""
|
||||
Test tool results with mixed content types (text and image) where only some have cache_control.
|
||||
"""
|
||||
from litellm.litellm_core_utils.prompt_templates.factory import (
|
||||
convert_to_anthropic_tool_result,
|
||||
)
|
||||
|
||||
message = {
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_test_789",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "First image:",
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": "data:image/jpeg;base64,/9j/4AAQSkZJRg",
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Second image (no cache):",
|
||||
},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": "data:image/png;base64,iVBORw0KGgo",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
result = convert_to_anthropic_tool_result(message)
|
||||
|
||||
assert result["type"] == "tool_result"
|
||||
assert isinstance(result["content"], list)
|
||||
assert len(result["content"]) == 4
|
||||
|
||||
# First text with cache_control
|
||||
assert result["content"][0]["type"] == "text"
|
||||
assert result["content"][0]["cache_control"]["type"] == "ephemeral"
|
||||
|
||||
# First image with cache_control
|
||||
assert result["content"][1]["type"] == "image"
|
||||
assert result["content"][1]["cache_control"]["type"] == "ephemeral"
|
||||
|
||||
# Second text without cache_control (cache_control will be None if not set)
|
||||
assert result["content"][2]["type"] == "text"
|
||||
assert result["content"][2].get("cache_control") is None
|
||||
|
||||
# Second image without cache_control (cache_control will be None if not set)
|
||||
assert result["content"][3]["type"] == "image"
|
||||
assert result["content"][3].get("cache_control") is None
|
||||
|
||||
|
||||
def test_convert_to_anthropic_tool_result_image_url_as_http():
|
||||
"""
|
||||
Test that HTTP/HTTPS URLs with cache_control are handled correctly.
|
||||
"""
|
||||
from litellm.litellm_core_utils.prompt_templates.factory import (
|
||||
convert_to_anthropic_tool_result,
|
||||
)
|
||||
|
||||
message = {
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_http_001",
|
||||
"content": [
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": "https://example.com/image.jpg",
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
result = convert_to_anthropic_tool_result(message)
|
||||
|
||||
# Verify image is passed as URL reference with cache_control
|
||||
assert result["content"][0]["type"] == "image"
|
||||
assert result["content"][0]["source"]["type"] == "url"
|
||||
assert result["content"][0]["source"]["url"] == "https://example.com/image.jpg"
|
||||
assert result["content"][0]["cache_control"]["type"] == "ephemeral"
|
||||
def test_anthropic_messages_pt_server_tool_use_passthrough():
|
||||
"""
|
||||
Test that anthropic_messages_pt passes through server_tool_use and
|
||||
|
|
|
|||
|
|
@ -393,6 +393,63 @@ def test_get_request_tags_from_metadata_and_litellm_metadata():
|
|||
assert "User-Agent: litellm/1.0.0" in tags
|
||||
|
||||
|
||||
def test_get_request_tags_does_not_mutate_original_tags():
|
||||
"""
|
||||
Test that _get_request_tags does not mutate the original tags list in metadata.
|
||||
|
||||
This is a regression test for a bug where calling _get_request_tags multiple times
|
||||
would cause User-Agent tags to be duplicated because the function was mutating
|
||||
the original tags list instead of creating a copy.
|
||||
"""
|
||||
from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup
|
||||
|
||||
# Create metadata with original tags
|
||||
original_tags = ["custom-tag-1", "custom-tag-2"]
|
||||
metadata = {"tags": original_tags}
|
||||
litellm_params = {"metadata": metadata}
|
||||
proxy_server_request = {
|
||||
"headers": {
|
||||
"user-agent": "AsyncOpenAI/Python 1.99.9",
|
||||
}
|
||||
}
|
||||
|
||||
# Call _get_request_tags multiple times (simulating multiple callbacks)
|
||||
tags1 = StandardLoggingPayloadSetup._get_request_tags(
|
||||
litellm_params=litellm_params,
|
||||
proxy_server_request=proxy_server_request,
|
||||
)
|
||||
tags2 = StandardLoggingPayloadSetup._get_request_tags(
|
||||
litellm_params=litellm_params,
|
||||
proxy_server_request=proxy_server_request,
|
||||
)
|
||||
tags3 = StandardLoggingPayloadSetup._get_request_tags(
|
||||
litellm_params=litellm_params,
|
||||
proxy_server_request=proxy_server_request,
|
||||
)
|
||||
|
||||
# Verify the original tags list was NOT mutated
|
||||
assert original_tags == ["custom-tag-1", "custom-tag-2"], (
|
||||
f"Original tags list was mutated: {original_tags}"
|
||||
)
|
||||
assert metadata["tags"] == ["custom-tag-1", "custom-tag-2"], (
|
||||
f"metadata['tags'] was mutated: {metadata['tags']}"
|
||||
)
|
||||
|
||||
# Verify each returned list has exactly 2 User-Agent tags (not duplicated)
|
||||
user_agent_count_1 = len([t for t in tags1 if t.startswith("User-Agent:")])
|
||||
user_agent_count_2 = len([t for t in tags2 if t.startswith("User-Agent:")])
|
||||
user_agent_count_3 = len([t for t in tags3 if t.startswith("User-Agent:")])
|
||||
|
||||
assert user_agent_count_1 == 2, f"Expected 2 User-Agent tags, got {user_agent_count_1}"
|
||||
assert user_agent_count_2 == 2, f"Expected 2 User-Agent tags, got {user_agent_count_2}"
|
||||
assert user_agent_count_3 == 2, f"Expected 2 User-Agent tags, got {user_agent_count_3}"
|
||||
|
||||
# Verify all returned lists are independent (different objects)
|
||||
assert tags1 is not tags2
|
||||
assert tags2 is not tags3
|
||||
assert tags1 is not original_tags
|
||||
|
||||
|
||||
def test_get_extra_header_tags():
|
||||
"""Test the _get_extra_header_tags method with various scenarios."""
|
||||
import litellm
|
||||
|
|
|
|||
|
|
@ -204,3 +204,62 @@ def test_azure_gpt5_reasoning_effort_none_dropped(config: AzureOpenAIGPT5Config)
|
|||
)
|
||||
assert "reasoning_effort" not in params or params.get("reasoning_effort") != "none"
|
||||
|
||||
|
||||
# Logprobs support tests for Azure GPT-5.2
|
||||
def test_azure_gpt5_2_supports_logprobs(config: AzureOpenAIGPT5Config):
|
||||
"""Test that Azure GPT-5.2 models support logprobs parameters.
|
||||
|
||||
Only Azure OpenAI GPT-5.2 supports logprobs, unlike OpenAI's GPT-5 or Azure's gpt-5/gpt-5.1.
|
||||
Tested with gpt-5.2 on api-version 2025-01-01-preview.
|
||||
"""
|
||||
supported_params = config.get_supported_openai_params(model="gpt-5.2")
|
||||
assert "logprobs" in supported_params
|
||||
assert "top_logprobs" in supported_params
|
||||
|
||||
|
||||
def test_azure_gpt5_2_with_prefix_supports_logprobs(config: AzureOpenAIGPT5Config):
|
||||
"""Test that Azure GPT-5.2 with azure/ prefix supports logprobs parameters."""
|
||||
supported_params = config.get_supported_openai_params(model="azure/gpt-5.2")
|
||||
assert "logprobs" in supported_params
|
||||
assert "top_logprobs" in supported_params
|
||||
|
||||
|
||||
def test_azure_gpt5_2_series_supports_logprobs(config: AzureOpenAIGPT5Config):
|
||||
"""Test that Azure GPT-5.2 with gpt5_series prefix supports logprobs."""
|
||||
supported_params = config.get_supported_openai_params(model="gpt5_series/gpt-5.2")
|
||||
assert "logprobs" in supported_params
|
||||
assert "top_logprobs" in supported_params
|
||||
|
||||
|
||||
def test_azure_gpt5_2_logprobs_params_passed_through(config: AzureOpenAIGPT5Config):
|
||||
"""Test that logprobs parameters are correctly passed through to the API for gpt-5.2."""
|
||||
params = config.map_openai_params(
|
||||
non_default_params={"logprobs": True, "top_logprobs": 5},
|
||||
optional_params={},
|
||||
model="azure/gpt-5.2",
|
||||
drop_params=False,
|
||||
api_version="2025-01-01-preview",
|
||||
)
|
||||
assert params["logprobs"] is True
|
||||
assert params["top_logprobs"] == 5
|
||||
|
||||
|
||||
def test_azure_gpt5_base_does_not_support_logprobs(config: AzureOpenAIGPT5Config):
|
||||
"""Test that Azure GPT-5 (non-5.2) does not support logprobs parameters.
|
||||
|
||||
Only gpt-5.2 has been verified to support logprobs on Azure.
|
||||
"""
|
||||
supported_params = config.get_supported_openai_params(model="gpt-5")
|
||||
assert "logprobs" not in supported_params
|
||||
assert "top_logprobs" not in supported_params
|
||||
|
||||
|
||||
def test_azure_gpt5_1_does_not_support_logprobs(config: AzureOpenAIGPT5Config):
|
||||
"""Test that Azure GPT-5.1 does not support logprobs parameters.
|
||||
|
||||
Only gpt-5.2 has been verified to support logprobs on Azure.
|
||||
"""
|
||||
supported_params = config.get_supported_openai_params(model="gpt-5.1")
|
||||
assert "logprobs" not in supported_params
|
||||
assert "top_logprobs" not in supported_params
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,132 @@
|
|||
"""
|
||||
Unit tests for OpenRouter embedding transformation logic.
|
||||
"""
|
||||
from litellm.llms.openrouter.embedding.transformation import (
|
||||
OpenrouterEmbeddingConfig,
|
||||
)
|
||||
|
||||
|
||||
def test_openrouter_embedding_supported_params():
|
||||
"""Test that supported OpenAI params are correctly defined."""
|
||||
config = OpenrouterEmbeddingConfig()
|
||||
supported = config.get_supported_openai_params("test-model")
|
||||
|
||||
assert "timeout" in supported
|
||||
assert "dimensions" in supported
|
||||
assert "encoding_format" in supported
|
||||
assert "user" in supported
|
||||
|
||||
|
||||
def test_openrouter_embedding_transform_request():
|
||||
"""Test request transformation logic."""
|
||||
config = OpenrouterEmbeddingConfig()
|
||||
|
||||
# Test with string input
|
||||
result = config.transform_embedding_request(
|
||||
model="openrouter/google/text-embedding-004",
|
||||
input="Hello world",
|
||||
optional_params={},
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert result["model"] == "google/text-embedding-004"
|
||||
assert result["input"] == ["Hello world"]
|
||||
|
||||
# Test with list input
|
||||
result = config.transform_embedding_request(
|
||||
model="google/text-embedding-004",
|
||||
input=["Hello", "World"],
|
||||
optional_params={"dimensions": 512},
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert result["model"] == "google/text-embedding-004"
|
||||
assert result["input"] == ["Hello", "World"]
|
||||
assert result["dimensions"] == 512
|
||||
|
||||
|
||||
def test_openrouter_embedding_validate_environment():
|
||||
"""Test environment validation and header setup."""
|
||||
config = OpenrouterEmbeddingConfig()
|
||||
|
||||
# Test with API key
|
||||
headers = config.validate_environment(
|
||||
headers={"Custom-Header": "value"},
|
||||
model="test-model",
|
||||
messages=[],
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
api_key="test-api-key",
|
||||
)
|
||||
|
||||
# Should include OpenRouter-specific headers
|
||||
assert "HTTP-Referer" in headers
|
||||
assert "X-Title" in headers
|
||||
# Should include Content-Type header
|
||||
assert "Content-Type" in headers
|
||||
assert headers["Content-Type"] == "application/json"
|
||||
# Should include Authorization header
|
||||
assert "Authorization" in headers
|
||||
assert headers["Authorization"] == "Bearer test-api-key"
|
||||
# Should preserve custom headers
|
||||
assert headers["Custom-Header"] == "value"
|
||||
|
||||
# Test without API key
|
||||
headers_no_key = config.validate_environment(
|
||||
headers={},
|
||||
model="test-model",
|
||||
messages=[],
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
api_key=None,
|
||||
)
|
||||
|
||||
# Should still include OpenRouter headers but not Authorization
|
||||
assert "HTTP-Referer" in headers_no_key
|
||||
assert "X-Title" in headers_no_key
|
||||
assert "Content-Type" in headers_no_key
|
||||
assert "Authorization" not in headers_no_key
|
||||
|
||||
|
||||
def test_openrouter_embedding_get_complete_url():
|
||||
"""Test URL construction."""
|
||||
config = OpenrouterEmbeddingConfig()
|
||||
|
||||
url = config.get_complete_url(
|
||||
api_base="https://openrouter.ai/api/v1",
|
||||
api_key="test-key",
|
||||
model="test-model",
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
)
|
||||
|
||||
assert url == "https://openrouter.ai/api/v1/embeddings"
|
||||
|
||||
# Test with trailing slash
|
||||
url = config.get_complete_url(
|
||||
api_base="https://openrouter.ai/api/v1/",
|
||||
api_key="test-key",
|
||||
model="test-model",
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
)
|
||||
|
||||
assert url == "https://openrouter.ai/api/v1/embeddings"
|
||||
|
||||
|
||||
def test_openrouter_embedding_map_params():
|
||||
"""Test parameter mapping."""
|
||||
config = OpenrouterEmbeddingConfig()
|
||||
|
||||
result = config.map_openai_params(
|
||||
non_default_params={"dimensions": 512, "timeout": 30, "unsupported": "value"},
|
||||
optional_params={},
|
||||
model="test-model",
|
||||
drop_params=False,
|
||||
)
|
||||
|
||||
# Supported params should be included
|
||||
assert result["dimensions"] == 512
|
||||
assert result["timeout"] == 30
|
||||
# Unsupported params should not be included
|
||||
assert "unsupported" not in result
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import pytest
|
||||
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexGeminiConfig
|
||||
from litellm import ModelResponse
|
||||
|
||||
def test_process_candidates_unbound_local_error_fix():
|
||||
# Setup
|
||||
candidates = [
|
||||
{
|
||||
"content": {
|
||||
"role": "model"
|
||||
# "parts" is missing intentionally to trigger the issue
|
||||
},
|
||||
"finishReason": "STOP"
|
||||
}
|
||||
]
|
||||
model_response = ModelResponse()
|
||||
|
||||
# Execution
|
||||
try:
|
||||
VertexGeminiConfig._process_candidates(
|
||||
_candidates=candidates,
|
||||
model_response=model_response,
|
||||
standard_optional_params={},
|
||||
cumulative_tool_call_index=0
|
||||
)
|
||||
except UnboundLocalError as e:
|
||||
pytest.fail(f"UnboundLocalError raised: {e}")
|
||||
except Exception as e:
|
||||
# Other exceptions might be okay if they are not UnboundLocalError,
|
||||
# but ideally it should pass without error or raise a specific error if parts are required.
|
||||
# However, the goal is to verify thought_signatures doesn't crash.
|
||||
pass
|
||||
|
||||
# Verify that we didn't crash with UnboundLocalError
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_process_candidates_unbound_local_error_fix()
|
||||
print("Test passed!")
|
||||
|
|
@ -13,6 +13,7 @@ sys.path.insert(
|
|||
|
||||
import litellm
|
||||
from litellm.llms.vertex_ai.vertex_llm_base import VertexBase
|
||||
from litellm.llms.vertex_ai.common_utils import _get_gemini_url
|
||||
|
||||
|
||||
def run_sync(coro):
|
||||
|
|
@ -1048,3 +1049,139 @@ class TestVertexBase:
|
|||
MockCredentials.from_info.assert_called_once_with(json_obj)
|
||||
mock_creds.with_scopes.assert_called_once_with(scopes)
|
||||
assert result == "scoped_creds"
|
||||
|
||||
def test_get_token_and_url_with_api_key(self):
|
||||
"""Test that API key authentication routes to Google AI Studio endpoint"""
|
||||
vertex_base = VertexBase()
|
||||
|
||||
# Test with API key and no credentials - should use Google AI Studio endpoint
|
||||
auth_header, url = vertex_base._get_token_and_url(
|
||||
model="gemini-2.0-flash-exp",
|
||||
auth_header=None,
|
||||
gemini_api_key="test-api-key-123",
|
||||
vertex_project="test-project",
|
||||
vertex_location="us-central1",
|
||||
vertex_credentials=None, # No service account credentials
|
||||
stream=False,
|
||||
custom_llm_provider="vertex_ai",
|
||||
api_base=None,
|
||||
should_use_v1beta1_features=False,
|
||||
mode="chat",
|
||||
)
|
||||
|
||||
# Should route to Google AI Studio endpoint
|
||||
assert "generativelanguage.googleapis.com" in url
|
||||
assert "gemini-2.0-flash-exp" in url
|
||||
assert "key=test-api-key-123" in url
|
||||
assert auth_header is None # API key is in URL, not header
|
||||
|
||||
def test_get_token_and_url_with_credentials(self):
|
||||
"""Test that service account credentials route to Vertex AI endpoint"""
|
||||
vertex_base = VertexBase()
|
||||
|
||||
mock_creds = MagicMock()
|
||||
mock_creds.token = "mock-bearer-token"
|
||||
mock_creds.expired = False
|
||||
|
||||
with patch.object(
|
||||
vertex_base, "_ensure_access_token", return_value=("mock-bearer-token", "test-project")
|
||||
):
|
||||
# Test with credentials - should use Vertex AI endpoint
|
||||
auth_header, url = vertex_base._get_token_and_url(
|
||||
model="gemini-2.0-flash-exp",
|
||||
auth_header="mock-bearer-token",
|
||||
gemini_api_key=None,
|
||||
vertex_project="test-project",
|
||||
vertex_location="us-central1",
|
||||
vertex_credentials={"type": "service_account"},
|
||||
stream=False,
|
||||
custom_llm_provider="vertex_ai",
|
||||
api_base=None,
|
||||
should_use_v1beta1_features=False,
|
||||
mode="chat",
|
||||
)
|
||||
|
||||
# Should route to Vertex AI endpoint
|
||||
assert "aiplatform.googleapis.com" in url
|
||||
assert "projects/test-project" in url
|
||||
assert "locations/us-central1" in url
|
||||
assert auth_header == "mock-bearer-token"
|
||||
|
||||
def test_get_token_and_url_api_key_with_streaming(self):
|
||||
"""Test API key authentication with streaming enabled"""
|
||||
vertex_base = VertexBase()
|
||||
|
||||
auth_header, url = vertex_base._get_token_and_url(
|
||||
model="gemini-2.0-flash-exp",
|
||||
auth_header=None,
|
||||
gemini_api_key="test-api-key-456",
|
||||
vertex_project="test-project",
|
||||
vertex_location="us-central1",
|
||||
vertex_credentials=None,
|
||||
stream=True, # Streaming enabled
|
||||
custom_llm_provider="vertex_ai",
|
||||
api_base=None,
|
||||
should_use_v1beta1_features=False,
|
||||
mode="chat",
|
||||
)
|
||||
|
||||
# Should route to Google AI Studio endpoint with streaming
|
||||
assert "generativelanguage.googleapis.com" in url
|
||||
assert "streamGenerateContent" in url
|
||||
assert "key=test-api-key-456" in url
|
||||
assert "alt=sse" in url
|
||||
assert auth_header is None
|
||||
|
||||
def test_get_token_and_url_api_key_priority(self):
|
||||
"""Test that credentials take priority over API key when both are provided"""
|
||||
vertex_base = VertexBase()
|
||||
|
||||
# When both API key and credentials are provided, credentials take priority
|
||||
mock_creds = MagicMock()
|
||||
mock_creds.token = "mock-bearer-token"
|
||||
mock_creds.expired = False
|
||||
|
||||
with patch.object(
|
||||
vertex_base, "_ensure_access_token", return_value=("mock-bearer-token", "test-project")
|
||||
):
|
||||
auth_header, url = vertex_base._get_token_and_url(
|
||||
model="gemini-2.0-flash-exp",
|
||||
auth_header="mock-bearer-token",
|
||||
gemini_api_key="test-api-key-789",
|
||||
vertex_project="test-project",
|
||||
vertex_location="us-central1",
|
||||
vertex_credentials={"type": "service_account"}, # Credentials provided
|
||||
stream=False,
|
||||
custom_llm_provider="vertex_ai",
|
||||
api_base=None,
|
||||
should_use_v1beta1_features=False,
|
||||
mode="chat",
|
||||
)
|
||||
|
||||
# Should use Vertex AI endpoint with Bearer token (credentials take priority)
|
||||
assert "aiplatform.googleapis.com" in url
|
||||
assert auth_header == "mock-bearer-token"
|
||||
|
||||
def test_get_token_and_url_with_embedding_mode(self):
|
||||
"""Test API key authentication with embedding mode"""
|
||||
vertex_base = VertexBase()
|
||||
|
||||
auth_header, url = vertex_base._get_token_and_url(
|
||||
model="text-embedding-004",
|
||||
auth_header=None,
|
||||
gemini_api_key="test-embedding-key",
|
||||
vertex_project="test-project",
|
||||
vertex_location="us-central1",
|
||||
vertex_credentials=None,
|
||||
stream=False,
|
||||
custom_llm_provider="vertex_ai",
|
||||
api_base=None,
|
||||
should_use_v1beta1_features=False,
|
||||
mode="embedding",
|
||||
)
|
||||
|
||||
# Should route to Google AI Studio endpoint for embeddings
|
||||
assert "generativelanguage.googleapis.com" in url
|
||||
assert "embedContent" in url
|
||||
assert "key=test-embedding-key" in url
|
||||
assert auth_header is None
|
||||
|
|
@ -338,6 +338,17 @@ async def test_proxy_admin_expired_key_from_cache():
|
|||
f"Exception message should mention 'Expired Key', got: {exc_info.value.message}"
|
||||
)
|
||||
|
||||
# Verify that the param field does NOT leak the full API key (Issue #18731)
|
||||
# The param should be abbreviated like "sk-...XXXX" not the full plaintext key
|
||||
assert exc_info.value.param is not None, "Exception should have 'param' attribute"
|
||||
assert exc_info.value.param != api_key, (
|
||||
f"SECURITY: Full API key should NOT be in param field! "
|
||||
f"Got: {exc_info.value.param}, Expected abbreviated format like 'sk-...XXXX'"
|
||||
)
|
||||
assert exc_info.value.param.startswith("sk-..."), (
|
||||
f"Param should be abbreviated to 'sk-...XXXX' format. Got: {exc_info.value.param}"
|
||||
)
|
||||
|
||||
# Verify that cache deletion was called
|
||||
mock_delete_cache.assert_called_once()
|
||||
call_args = mock_delete_cache.call_args
|
||||
|
|
@ -349,6 +360,7 @@ async def test_proxy_admin_expired_key_from_cache():
|
|||
pass
|
||||
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_return_user_api_key_auth_obj_user_spend_and_budget():
|
||||
"""
|
||||
|
|
|
|||
275
tests/test_litellm/proxy/db/test_rds_iam_token_expiry.py
Normal file
275
tests/test_litellm/proxy/db/test_rds_iam_token_expiry.py
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
"""
|
||||
Tests for the RDS IAM token proactive refresh implementation.
|
||||
|
||||
Tests for GitHub Issue #16220: RDS IAM authentication connection failures after 15 minutes.
|
||||
|
||||
The fix implements:
|
||||
1. Proactive background token refresh (refreshes 3 min before expiration)
|
||||
2. Precise sleep timing (1 wake-up per token cycle instead of polling)
|
||||
3. Proper locking during reconnection
|
||||
4. Fixed __getattr__ fallback that now waits for reconnection
|
||||
|
||||
Run these tests:
|
||||
poetry run pytest tests/test_litellm/proxy/db/test_rds_iam_token_expiry.py -v -s
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import urllib.parse
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestPrismaWrapperTokenRefresh:
|
||||
"""Tests for the PrismaWrapper RDS IAM token refresh implementation."""
|
||||
|
||||
@pytest.fixture
|
||||
def setup_env(self):
|
||||
"""Setup environment variables for testing."""
|
||||
os.environ["DATABASE_HOST"] = "test-host.rds.amazonaws.com"
|
||||
os.environ["DATABASE_PORT"] = "5432"
|
||||
os.environ["DATABASE_USER"] = "test_user"
|
||||
os.environ["DATABASE_NAME"] = "test_db"
|
||||
os.environ["IAM_TOKEN_DB_AUTH"] = "True"
|
||||
yield
|
||||
# Cleanup
|
||||
for key in [
|
||||
"DATABASE_HOST",
|
||||
"DATABASE_PORT",
|
||||
"DATABASE_USER",
|
||||
"DATABASE_NAME",
|
||||
"DATABASE_URL",
|
||||
"IAM_TOKEN_DB_AUTH",
|
||||
"DATABASE_SCHEMA",
|
||||
]:
|
||||
os.environ.pop(key, None)
|
||||
|
||||
def _generate_mock_token(self, expires_in_seconds: int = 900) -> str:
|
||||
"""Generate a mock IAM token with expiration info."""
|
||||
now = datetime.utcnow()
|
||||
date_str = now.strftime("%Y%m%dT%H%M%SZ")
|
||||
# Build the token like AWS does
|
||||
token = f"mock-token?X-Amz-Date={date_str}&X-Amz-Expires={expires_in_seconds}&X-Amz-Signature=abc123"
|
||||
return urllib.parse.quote(token, safe="")
|
||||
|
||||
def _set_database_url_with_token(self, expires_in_seconds: int = 900):
|
||||
"""Set DATABASE_URL with a mock token."""
|
||||
token = self._generate_mock_token(expires_in_seconds)
|
||||
os.environ[
|
||||
"DATABASE_URL"
|
||||
] = f"postgresql://test_user:{token}@test-host:5432/test_db"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_token_expired_fresh(self, setup_env):
|
||||
"""Test that fresh token is not detected as expired."""
|
||||
from litellm.proxy.db.prisma_client import PrismaWrapper
|
||||
|
||||
mock_prisma = MagicMock()
|
||||
wrapper = PrismaWrapper(original_prisma=mock_prisma, iam_token_db_auth=True)
|
||||
|
||||
self._set_database_url_with_token(expires_in_seconds=900)
|
||||
db_url = os.getenv("DATABASE_URL")
|
||||
|
||||
assert wrapper.is_token_expired(db_url) is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_token_expired_old(self, setup_env):
|
||||
"""Test that old token is detected as expired."""
|
||||
from litellm.proxy.db.prisma_client import PrismaWrapper
|
||||
|
||||
mock_prisma = MagicMock()
|
||||
wrapper = PrismaWrapper(original_prisma=mock_prisma, iam_token_db_auth=True)
|
||||
|
||||
# Create an expired token
|
||||
old_date = datetime.utcnow() - timedelta(seconds=901)
|
||||
date_str = old_date.strftime("%Y%m%dT%H%M%SZ")
|
||||
token = (
|
||||
f"mock-token?X-Amz-Date={date_str}&X-Amz-Expires=900&X-Amz-Signature=abc"
|
||||
)
|
||||
encoded_token = urllib.parse.quote(token, safe="")
|
||||
db_url = f"postgresql://test_user:{encoded_token}@test-host:5432/test_db"
|
||||
|
||||
assert wrapper.is_token_expired(db_url) is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_stop_token_refresh_task(self, setup_env):
|
||||
"""Test that token refresh task starts and stops correctly."""
|
||||
from litellm.proxy.db.prisma_client import PrismaWrapper
|
||||
|
||||
mock_prisma = MagicMock()
|
||||
wrapper = PrismaWrapper(original_prisma=mock_prisma, iam_token_db_auth=True)
|
||||
|
||||
# Set a valid token
|
||||
self._set_database_url_with_token(expires_in_seconds=900)
|
||||
|
||||
# Start the task
|
||||
await wrapper.start_token_refresh_task()
|
||||
assert wrapper._token_refresh_task is not None
|
||||
assert not wrapper._token_refresh_task.done()
|
||||
|
||||
# Stop the task
|
||||
await wrapper.stop_token_refresh_task()
|
||||
assert wrapper._token_refresh_task is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_task_not_enabled(self, setup_env):
|
||||
"""Test that task doesn't start when IAM auth is not enabled."""
|
||||
from litellm.proxy.db.prisma_client import PrismaWrapper
|
||||
|
||||
mock_prisma = MagicMock()
|
||||
# IAM auth disabled
|
||||
wrapper = PrismaWrapper(original_prisma=mock_prisma, iam_token_db_auth=False)
|
||||
|
||||
await wrapper.start_token_refresh_task()
|
||||
assert wrapper._token_refresh_task is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_token_expired_null(self, setup_env):
|
||||
"""Test that None token is treated as expired."""
|
||||
from litellm.proxy.db.prisma_client import PrismaWrapper
|
||||
|
||||
mock_prisma = MagicMock()
|
||||
wrapper = PrismaWrapper(original_prisma=mock_prisma, iam_token_db_auth=True)
|
||||
|
||||
assert wrapper.is_token_expired(None) is True
|
||||
|
||||
|
||||
class TestTokenExpirationParsing:
|
||||
"""Tests for token expiration parsing utilities."""
|
||||
|
||||
def test_parse_token_expiration_valid(self):
|
||||
"""Test parsing expiration from a valid token."""
|
||||
from litellm.proxy.db.prisma_client import PrismaWrapper
|
||||
|
||||
mock_prisma = MagicMock()
|
||||
wrapper = PrismaWrapper(original_prisma=mock_prisma, iam_token_db_auth=True)
|
||||
|
||||
# Create a token with known expiration
|
||||
token = "mock-token?X-Amz-Date=20240101T120000Z&X-Amz-Expires=900&X-Amz-Signature=abc"
|
||||
|
||||
expiration = wrapper._parse_token_expiration(token)
|
||||
|
||||
assert expiration is not None
|
||||
expected = datetime(2024, 1, 1, 12, 0, 0) + timedelta(seconds=900)
|
||||
assert expiration == expected
|
||||
|
||||
def test_parse_token_expiration_invalid(self):
|
||||
"""Test that invalid token returns None."""
|
||||
from litellm.proxy.db.prisma_client import PrismaWrapper
|
||||
|
||||
mock_prisma = MagicMock()
|
||||
wrapper = PrismaWrapper(original_prisma=mock_prisma, iam_token_db_auth=True)
|
||||
|
||||
# Invalid tokens
|
||||
assert wrapper._parse_token_expiration(None) is None
|
||||
assert wrapper._parse_token_expiration("no-query-params") is None
|
||||
assert wrapper._parse_token_expiration("?missing=params") is None
|
||||
|
||||
|
||||
class TestBackgroundRefreshLoop:
|
||||
"""Tests for the background refresh loop timing."""
|
||||
|
||||
@pytest.fixture
|
||||
def setup_env(self):
|
||||
"""Setup environment variables for testing."""
|
||||
os.environ["DATABASE_HOST"] = "test-host.rds.amazonaws.com"
|
||||
os.environ["DATABASE_PORT"] = "5432"
|
||||
os.environ["DATABASE_USER"] = "test_user"
|
||||
os.environ["DATABASE_NAME"] = "test_db"
|
||||
yield
|
||||
# Cleanup
|
||||
for key in [
|
||||
"DATABASE_HOST",
|
||||
"DATABASE_PORT",
|
||||
"DATABASE_USER",
|
||||
"DATABASE_NAME",
|
||||
"DATABASE_URL",
|
||||
]:
|
||||
os.environ.pop(key, None)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_calculate_seconds_fallback_when_no_url(self, setup_env):
|
||||
"""Test that fallback is used when DATABASE_URL is not set."""
|
||||
from litellm.proxy.db.prisma_client import PrismaWrapper
|
||||
|
||||
mock_prisma = MagicMock()
|
||||
wrapper = PrismaWrapper(original_prisma=mock_prisma, iam_token_db_auth=True)
|
||||
|
||||
# Don't set DATABASE_URL
|
||||
seconds = wrapper._calculate_seconds_until_refresh()
|
||||
|
||||
# Should return fallback interval
|
||||
assert seconds == wrapper.FALLBACK_REFRESH_INTERVAL_SECONDS
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# DEMONSTRATION SCRIPT
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def demonstrate_fix():
|
||||
"""
|
||||
Demonstrates the fix for the RDS IAM token expiration bug.
|
||||
|
||||
Shows how the proactive refresh prevents the 15-minute connection failure.
|
||||
"""
|
||||
# Import the actual implementation
|
||||
try:
|
||||
from litellm.proxy.db.prisma_client import PrismaWrapper
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
# Setup mock environment
|
||||
os.environ["DATABASE_HOST"] = "mock-rds.region.rds.amazonaws.com"
|
||||
os.environ["DATABASE_PORT"] = "5432"
|
||||
os.environ["DATABASE_USER"] = "iam_user"
|
||||
os.environ["DATABASE_NAME"] = "litellm"
|
||||
|
||||
# Create initial token (expires in 10 seconds for demo)
|
||||
now = datetime.utcnow()
|
||||
date_str = now.strftime("%Y%m%dT%H%M%SZ")
|
||||
token = f"mock-token?X-Amz-Date={date_str}&X-Amz-Expires=10&X-Amz-Signature=abc123"
|
||||
encoded_token = urllib.parse.quote(token, safe="")
|
||||
os.environ[
|
||||
"DATABASE_URL"
|
||||
] = f"postgresql://iam_user:{encoded_token}@mock-rds:5432/litellm"
|
||||
|
||||
# Create mock prisma client
|
||||
mock_prisma = MagicMock()
|
||||
|
||||
wrapper = PrismaWrapper(original_prisma=mock_prisma, iam_token_db_auth=True)
|
||||
|
||||
# Override buffer for faster demo
|
||||
wrapper.TOKEN_REFRESH_BUFFER_SECONDS = 3
|
||||
wrapper.FALLBACK_REFRESH_INTERVAL_SECONDS = 5
|
||||
_ = wrapper._calculate_seconds_until_refresh() # Verify calculation works
|
||||
db_url = os.getenv("DATABASE_URL")
|
||||
is_expired = wrapper.is_token_expired(db_url)
|
||||
assert is_expired is False, "Fresh token should not be expired!"
|
||||
|
||||
# Mock the _token_refresh_loop to prevent it from actually running
|
||||
async def mock_loop():
|
||||
try:
|
||||
await asyncio.sleep(1000)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
with patch.object(wrapper, "_token_refresh_loop", side_effect=mock_loop):
|
||||
await wrapper.start_token_refresh_task()
|
||||
await wrapper.stop_token_refresh_task()
|
||||
|
||||
# Cleanup
|
||||
for key in [
|
||||
"DATABASE_HOST",
|
||||
"DATABASE_PORT",
|
||||
"DATABASE_USER",
|
||||
"DATABASE_NAME",
|
||||
"DATABASE_URL",
|
||||
]:
|
||||
os.environ.pop(key, None)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(demonstrate_fix())
|
||||
|
|
@ -2,7 +2,6 @@
|
|||
Unit tests for Qualifire guardrail integration.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
|
@ -75,9 +74,37 @@ class TestQualifireGuardrailInit:
|
|||
|
||||
assert guardrail.on_flagged == "monitor"
|
||||
|
||||
def test_init_with_default_api_base(self):
|
||||
"""Test that default API base is set when not provided."""
|
||||
from litellm.proxy.guardrails.guardrail_hooks.qualifire.qualifire import (
|
||||
DEFAULT_QUALIFIRE_API_BASE,
|
||||
QualifireGuardrail,
|
||||
)
|
||||
|
||||
guardrail = QualifireGuardrail(
|
||||
api_key="test_key",
|
||||
guardrail_name="test_guardrail",
|
||||
)
|
||||
|
||||
assert guardrail.qualifire_api_base == DEFAULT_QUALIFIRE_API_BASE
|
||||
|
||||
def test_init_with_custom_api_base(self):
|
||||
"""Test initialization with custom API base URL."""
|
||||
from litellm.proxy.guardrails.guardrail_hooks.qualifire.qualifire import (
|
||||
QualifireGuardrail,
|
||||
)
|
||||
|
||||
guardrail = QualifireGuardrail(
|
||||
api_key="test_key",
|
||||
api_base="https://custom.qualifire.ai",
|
||||
guardrail_name="test_guardrail",
|
||||
)
|
||||
|
||||
assert guardrail.qualifire_api_base == "https://custom.qualifire.ai"
|
||||
|
||||
|
||||
class TestQualifireGuardrailMessageConversion:
|
||||
"""Tests for message conversion to Qualifire format."""
|
||||
"""Tests for message conversion to API format."""
|
||||
|
||||
def test_convert_simple_messages(self):
|
||||
"""Test conversion of simple text messages."""
|
||||
|
|
@ -95,15 +122,13 @@ class TestQualifireGuardrailMessageConversion:
|
|||
{"role": "assistant", "content": "Hi there!"},
|
||||
]
|
||||
|
||||
# Create mock LLMMessage class
|
||||
mock_llm_message = MagicMock()
|
||||
result = guardrail._convert_messages_to_api_format(messages)
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.guardrails.guardrail_hooks.qualifire.qualifire.QualifireGuardrail._convert_messages_to_qualifire_format"
|
||||
) as mock_convert:
|
||||
mock_convert.return_value = [mock_llm_message, mock_llm_message]
|
||||
result = guardrail._convert_messages_to_qualifire_format(messages)
|
||||
assert len(result) == 2
|
||||
assert len(result) == 2
|
||||
assert result[0]["role"] == "user"
|
||||
assert result[0]["content"] == "Hello, world!"
|
||||
assert result[1]["role"] == "assistant"
|
||||
assert result[1]["content"] == "Hi there!"
|
||||
|
||||
def test_convert_multimodal_messages(self):
|
||||
"""Test conversion of multimodal messages with text parts."""
|
||||
|
|
@ -126,112 +151,258 @@ class TestQualifireGuardrailMessageConversion:
|
|||
},
|
||||
]
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.guardrails.guardrail_hooks.qualifire.qualifire.QualifireGuardrail._convert_messages_to_qualifire_format"
|
||||
) as mock_convert:
|
||||
mock_convert.return_value = [MagicMock()]
|
||||
result = guardrail._convert_messages_to_qualifire_format(messages)
|
||||
assert len(result) == 1
|
||||
result = guardrail._convert_messages_to_api_format(messages)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["role"] == "user"
|
||||
assert result[0]["content"] == "First part\nSecond part"
|
||||
|
||||
def test_convert_messages_with_tool_calls(self):
|
||||
"""Test conversion of messages with tool calls."""
|
||||
from litellm.proxy.guardrails.guardrail_hooks.qualifire.qualifire import (
|
||||
QualifireGuardrail,
|
||||
)
|
||||
|
||||
guardrail = QualifireGuardrail(
|
||||
api_key="test_key",
|
||||
guardrail_name="test_guardrail",
|
||||
)
|
||||
|
||||
messages = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_123",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"arguments": '{"location": "NYC"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
result = guardrail._convert_messages_to_api_format(messages)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["role"] == "assistant"
|
||||
assert "tool_calls" in result[0]
|
||||
assert len(result[0]["tool_calls"]) == 1
|
||||
assert result[0]["tool_calls"][0]["id"] == "call_123"
|
||||
assert result[0]["tool_calls"][0]["name"] == "get_weather"
|
||||
assert result[0]["tool_calls"][0]["arguments"] == {"location": "NYC"}
|
||||
|
||||
|
||||
class TestQualifireGuardrailEvaluateKwargs:
|
||||
"""Tests for evaluate kwargs passed to Qualifire client."""
|
||||
class TestQualifireGuardrailToolConversion:
|
||||
"""Tests for tool definition conversion."""
|
||||
|
||||
def test_convert_openai_function_tools(self):
|
||||
"""Test conversion of OpenAI function tool format."""
|
||||
from litellm.proxy.guardrails.guardrail_hooks.qualifire.qualifire import (
|
||||
QualifireGuardrail,
|
||||
)
|
||||
|
||||
guardrail = QualifireGuardrail(
|
||||
api_key="test_key",
|
||||
guardrail_name="test_guardrail",
|
||||
)
|
||||
|
||||
tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"description": "Get weather for a location",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
result = guardrail._convert_tools_to_api_format(tools)
|
||||
|
||||
assert result is not None
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "get_weather"
|
||||
assert result[0]["description"] == "Get weather for a location"
|
||||
|
||||
def test_convert_empty_tools(self):
|
||||
"""Test that empty tools returns None."""
|
||||
from litellm.proxy.guardrails.guardrail_hooks.qualifire.qualifire import (
|
||||
QualifireGuardrail,
|
||||
)
|
||||
|
||||
guardrail = QualifireGuardrail(
|
||||
api_key="test_key",
|
||||
guardrail_name="test_guardrail",
|
||||
)
|
||||
|
||||
result = guardrail._convert_tools_to_api_format(None)
|
||||
assert result is None
|
||||
|
||||
result = guardrail._convert_tools_to_api_format([])
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestQualifireGuardrailAPICall:
|
||||
"""Tests for API call with httpx client."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_evaluate_called_with_prompt_injections(self):
|
||||
"""Test that evaluate is called with prompt_injections enabled."""
|
||||
# Mock the qualifire module and its types
|
||||
mock_qualifire_types = MagicMock()
|
||||
mock_llm_message = MagicMock()
|
||||
mock_llm_tool_call = MagicMock()
|
||||
mock_message_instance = MagicMock()
|
||||
mock_llm_message.return_value = mock_message_instance
|
||||
|
||||
mock_qualifire_types.LLMMessage = mock_llm_message
|
||||
mock_qualifire_types.LLMToolCall = mock_llm_tool_call
|
||||
|
||||
with patch.dict('sys.modules', {'qualifire': MagicMock(), 'qualifire.types': mock_qualifire_types}):
|
||||
from litellm.proxy.guardrails.guardrail_hooks.qualifire.qualifire import (
|
||||
QualifireGuardrail,
|
||||
)
|
||||
"""Test that evaluate endpoint is called with prompt_injections enabled."""
|
||||
from litellm.proxy.guardrails.guardrail_hooks.qualifire.qualifire import (
|
||||
QualifireGuardrail,
|
||||
)
|
||||
|
||||
guardrail = QualifireGuardrail(
|
||||
api_key="test_key",
|
||||
prompt_injections=True,
|
||||
guardrail_name="test_guardrail",
|
||||
)
|
||||
guardrail = QualifireGuardrail(
|
||||
api_key="test_key",
|
||||
prompt_injections=True,
|
||||
guardrail_name="test_guardrail",
|
||||
)
|
||||
|
||||
# Mock the client
|
||||
mock_client = MagicMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.score = 100
|
||||
mock_result.status = "completed"
|
||||
mock_result.evaluationResults = []
|
||||
mock_client.evaluate.return_value = mock_result
|
||||
guardrail._client = mock_client
|
||||
# Mock the async HTTP handler
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"score": 100,
|
||||
"status": "completed",
|
||||
"evaluationResults": [],
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
guardrail.async_handler.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
messages = [{"role": "user", "content": "Hello, world!"}]
|
||||
messages = [{"role": "user", "content": "Hello, world!"}]
|
||||
|
||||
await guardrail._run_qualifire_check(
|
||||
messages=messages, output=None, dynamic_params={}
|
||||
)
|
||||
await guardrail._run_qualifire_check(
|
||||
messages=messages, output=None, dynamic_params={}
|
||||
)
|
||||
|
||||
# Verify evaluate was called with correct kwargs
|
||||
mock_client.evaluate.assert_called_once()
|
||||
call_kwargs = mock_client.evaluate.call_args[1]
|
||||
assert call_kwargs["prompt_injections"] is True
|
||||
assert "messages" in call_kwargs
|
||||
# Verify the API was called
|
||||
guardrail.async_handler.post.assert_called_once()
|
||||
call_kwargs = guardrail.async_handler.post.call_args[1]
|
||||
|
||||
assert "json" in call_kwargs
|
||||
payload = call_kwargs["json"]
|
||||
assert payload["prompt_injections"] is True
|
||||
assert "messages" in payload
|
||||
assert call_kwargs["url"].endswith("/api/evaluation/evaluate")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_evaluate_called_with_multiple_checks(self):
|
||||
"""Test that evaluate is called with multiple checks enabled."""
|
||||
# Mock the qualifire module and its types
|
||||
mock_qualifire_types = MagicMock()
|
||||
mock_llm_message = MagicMock()
|
||||
mock_llm_tool_call = MagicMock()
|
||||
mock_message_instance = MagicMock()
|
||||
mock_llm_message.return_value = mock_message_instance
|
||||
|
||||
mock_qualifire_types.LLMMessage = mock_llm_message
|
||||
mock_qualifire_types.LLMToolCall = mock_llm_tool_call
|
||||
|
||||
with patch.dict('sys.modules', {'qualifire': MagicMock(), 'qualifire.types': mock_qualifire_types}):
|
||||
from litellm.proxy.guardrails.guardrail_hooks.qualifire.qualifire import (
|
||||
QualifireGuardrail,
|
||||
)
|
||||
from litellm.proxy.guardrails.guardrail_hooks.qualifire.qualifire import (
|
||||
QualifireGuardrail,
|
||||
)
|
||||
|
||||
guardrail = QualifireGuardrail(
|
||||
api_key="test_key",
|
||||
prompt_injections=True,
|
||||
pii_check=True,
|
||||
hallucinations_check=True,
|
||||
assertions=["Output must be valid JSON"],
|
||||
guardrail_name="test_guardrail",
|
||||
)
|
||||
guardrail = QualifireGuardrail(
|
||||
api_key="test_key",
|
||||
prompt_injections=True,
|
||||
pii_check=True,
|
||||
hallucinations_check=True,
|
||||
assertions=["Output must be valid JSON"],
|
||||
guardrail_name="test_guardrail",
|
||||
)
|
||||
|
||||
# Mock the client
|
||||
mock_client = MagicMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.score = 100
|
||||
mock_result.status = "completed"
|
||||
mock_result.evaluationResults = []
|
||||
mock_client.evaluate.return_value = mock_result
|
||||
guardrail._client = mock_client
|
||||
# Mock the async HTTP handler
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"score": 100,
|
||||
"status": "completed",
|
||||
"evaluationResults": [],
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
guardrail.async_handler.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
messages = [{"role": "user", "content": "Hello, world!"}]
|
||||
messages = [{"role": "user", "content": "Hello, world!"}]
|
||||
|
||||
await guardrail._run_qualifire_check(
|
||||
messages=messages, output="Test output", dynamic_params={}
|
||||
)
|
||||
await guardrail._run_qualifire_check(
|
||||
messages=messages, output="Test output", dynamic_params={}
|
||||
)
|
||||
|
||||
# Verify evaluate was called with correct kwargs
|
||||
mock_client.evaluate.assert_called_once()
|
||||
call_kwargs = mock_client.evaluate.call_args[1]
|
||||
assert call_kwargs["prompt_injections"] is True
|
||||
assert call_kwargs["pii_check"] is True
|
||||
assert call_kwargs["hallucinations_check"] is True
|
||||
assert call_kwargs["assertions"] == ["Output must be valid JSON"]
|
||||
assert call_kwargs["output"] == "Test output"
|
||||
# Verify the API was called with correct payload
|
||||
guardrail.async_handler.post.assert_called_once()
|
||||
call_kwargs = guardrail.async_handler.post.call_args[1]
|
||||
|
||||
payload = call_kwargs["json"]
|
||||
assert payload["prompt_injections"] is True
|
||||
assert payload["pii_check"] is True
|
||||
assert payload["hallucinations_check"] is True
|
||||
assert payload["assertions"] == ["Output must be valid JSON"]
|
||||
assert payload["output"] == "Test output"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoke_endpoint_used_with_evaluation_id(self):
|
||||
"""Test that invoke endpoint is used when evaluation_id is provided."""
|
||||
from litellm.proxy.guardrails.guardrail_hooks.qualifire.qualifire import (
|
||||
QualifireGuardrail,
|
||||
)
|
||||
|
||||
guardrail = QualifireGuardrail(
|
||||
api_key="test_key",
|
||||
evaluation_id="eval_123",
|
||||
guardrail_name="test_guardrail",
|
||||
)
|
||||
|
||||
# Mock the async HTTP handler
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"score": 100,
|
||||
"status": "completed",
|
||||
"evaluationResults": [],
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
guardrail.async_handler.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
messages = [{"role": "user", "content": "Hello, world!"}]
|
||||
|
||||
await guardrail._run_qualifire_check(
|
||||
messages=messages, output="Test output", dynamic_params={}
|
||||
)
|
||||
|
||||
# Verify the invoke endpoint was called
|
||||
guardrail.async_handler.post.assert_called_once()
|
||||
call_kwargs = guardrail.async_handler.post.call_args[1]
|
||||
|
||||
assert call_kwargs["url"].endswith("/api/evaluation/invoke")
|
||||
payload = call_kwargs["json"]
|
||||
assert payload["evaluation_id"] == "eval_123"
|
||||
assert payload["input"] == "Hello, world!"
|
||||
assert payload["output"] == "Test output"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_correct_headers_sent(self):
|
||||
"""Test that correct headers are sent with the API request."""
|
||||
from litellm.proxy.guardrails.guardrail_hooks.qualifire.qualifire import (
|
||||
QualifireGuardrail,
|
||||
)
|
||||
|
||||
guardrail = QualifireGuardrail(
|
||||
api_key="my_api_key",
|
||||
guardrail_name="test_guardrail",
|
||||
)
|
||||
|
||||
# Mock the async HTTP handler
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"score": 100,
|
||||
"status": "completed",
|
||||
"evaluationResults": [],
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
guardrail.async_handler.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
messages = [{"role": "user", "content": "Hello!"}]
|
||||
|
||||
await guardrail._run_qualifire_check(
|
||||
messages=messages, output=None, dynamic_params={}
|
||||
)
|
||||
|
||||
call_kwargs = guardrail.async_handler.post.call_args[1]
|
||||
headers = call_kwargs["headers"]
|
||||
|
||||
assert headers["X-Qualifire-API-Key"] == "my_api_key"
|
||||
assert headers["Content-Type"] == "application/json"
|
||||
|
||||
|
||||
class TestQualifireGuardrailCheckIfFlagged:
|
||||
|
|
@ -248,12 +419,14 @@ class TestQualifireGuardrailCheckIfFlagged:
|
|||
guardrail_name="test_guardrail",
|
||||
)
|
||||
|
||||
# Mock result with completed status and no flagged items
|
||||
mock_result = MagicMock()
|
||||
mock_result.status = "completed"
|
||||
mock_result.evaluationResults = []
|
||||
# Result with completed status and no flagged items (dict format)
|
||||
result = {
|
||||
"status": "completed",
|
||||
"score": 100,
|
||||
"evaluationResults": [],
|
||||
}
|
||||
|
||||
assert guardrail._check_if_flagged(mock_result) is False
|
||||
assert guardrail._check_if_flagged(result) is False
|
||||
|
||||
def test_check_if_flagged_returns_true_for_flagged_content(self):
|
||||
"""Test that _check_if_flagged returns True when content is flagged."""
|
||||
|
|
@ -266,18 +439,25 @@ class TestQualifireGuardrailCheckIfFlagged:
|
|||
guardrail_name="test_guardrail",
|
||||
)
|
||||
|
||||
# Mock result with flagged item
|
||||
mock_inner_result = MagicMock()
|
||||
mock_inner_result.flagged = True
|
||||
# Result with flagged item (dict format matching API response)
|
||||
result = {
|
||||
"status": "completed",
|
||||
"score": 15,
|
||||
"evaluationResults": [
|
||||
{
|
||||
"type": "prompt_injection",
|
||||
"results": [
|
||||
{
|
||||
"flagged": True,
|
||||
"score": 0.15,
|
||||
"reason": "Prompt injection detected",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
mock_eval_result = MagicMock()
|
||||
mock_eval_result.results = [mock_inner_result]
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.status = "completed"
|
||||
mock_result.evaluationResults = [mock_eval_result]
|
||||
|
||||
assert guardrail._check_if_flagged(mock_result) is True
|
||||
assert guardrail._check_if_flagged(result) is True
|
||||
|
||||
def test_check_if_flagged_returns_false_when_no_flagged_items(self):
|
||||
"""Test that _check_if_flagged returns False when no items are flagged."""
|
||||
|
|
@ -291,17 +471,24 @@ class TestQualifireGuardrailCheckIfFlagged:
|
|||
)
|
||||
|
||||
# Result with evaluation results but nothing flagged
|
||||
mock_inner_result = MagicMock()
|
||||
mock_inner_result.flagged = False
|
||||
result = {
|
||||
"status": "completed",
|
||||
"score": 95,
|
||||
"evaluationResults": [
|
||||
{
|
||||
"type": "prompt_injection",
|
||||
"results": [
|
||||
{
|
||||
"flagged": False,
|
||||
"score": 0.95,
|
||||
"reason": "No issues detected",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
mock_eval_result = MagicMock()
|
||||
mock_eval_result.results = [mock_inner_result]
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.status = "success"
|
||||
mock_result.evaluationResults = [mock_eval_result]
|
||||
|
||||
assert guardrail._check_if_flagged(mock_result) is False
|
||||
assert guardrail._check_if_flagged(result) is False
|
||||
|
||||
|
||||
class TestQualifireGuardrailShouldRun:
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
import json
|
||||
import os
|
||||
import sys
|
||||
from litellm._uuid import uuid
|
||||
import types
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from litellm._uuid import uuid
|
||||
|
||||
sys.path.insert(
|
||||
0, os.path.abspath("../../../..")
|
||||
) # Adds the parent directory to the system path
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from litellm.proxy._types import (
|
||||
LiteLLM_MCPServerTable,
|
||||
LitellmUserRoles,
|
||||
|
|
@ -118,6 +118,22 @@ def setup_mock_prisma_client(
|
|||
return mock_prisma_client
|
||||
|
||||
|
||||
def create_mcp_router_test_client() -> TestClient:
|
||||
from litellm.proxy.management_endpoints.mcp_management_endpoints import router
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def patch_proxy_general_settings(settings: dict):
|
||||
fake_proxy_server_module = types.SimpleNamespace(general_settings=settings)
|
||||
return patch.dict(
|
||||
sys.modules,
|
||||
{"litellm.proxy.proxy_server": fake_proxy_server_module},
|
||||
)
|
||||
|
||||
|
||||
class TestListMCPServers:
|
||||
"""Test suite for list MCP servers functionality"""
|
||||
|
||||
|
|
@ -1082,6 +1098,55 @@ class TestHealthCheckServers:
|
|||
assert result[1]["server_id"] == "server-2"
|
||||
assert result[1]["status"] == "unhealthy"
|
||||
|
||||
|
||||
class TestMCPRegistryEndpoint:
|
||||
def test_registry_returns_404_when_flag_missing(self):
|
||||
client = create_mcp_router_test_client()
|
||||
|
||||
with patch_proxy_general_settings({}):
|
||||
response = client.get("/v1/mcp/registry.json")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_registry_returns_404_when_flag_false(self):
|
||||
client = create_mcp_router_test_client()
|
||||
|
||||
with patch_proxy_general_settings({"enable_mcp_registry": False}):
|
||||
response = client.get("/v1/mcp/registry.json")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_registry_returns_entries_when_enabled(self):
|
||||
client = create_mcp_router_test_client()
|
||||
|
||||
mock_server = generate_mock_mcp_server_config_record(
|
||||
server_id="server-123",
|
||||
name="zapier",
|
||||
url="https://zapier.example.com/mcp",
|
||||
transport="http",
|
||||
)
|
||||
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.get_registry.return_value = {mock_server.server_id: mock_server}
|
||||
|
||||
with patch_proxy_general_settings({"enable_mcp_registry": True}), patch(
|
||||
"litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager",
|
||||
mock_manager,
|
||||
):
|
||||
response = client.get("/v1/mcp/registry.json")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["servers"]) == 2 # built-in + custom server
|
||||
|
||||
builtin_entry = data["servers"][0]["server"]
|
||||
assert builtin_entry["name"] == "litellm-mcp-server"
|
||||
assert builtin_entry["remotes"][0]["url"].endswith("/mcp")
|
||||
|
||||
custom_entry = data["servers"][1]["server"]
|
||||
assert custom_entry["name"] == "zapier"
|
||||
assert custom_entry["remotes"][0]["url"].endswith("/zapier/mcp")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check_specific_servers(self):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, MagicMock
|
|||
|
||||
import pytest
|
||||
from fastapi import Request, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
import litellm
|
||||
from litellm._uuid import uuid
|
||||
|
|
@ -11,9 +11,10 @@ from litellm.integrations.opentelemetry import UserAPIKeyAuth
|
|||
from litellm.proxy.common_request_processing import (
|
||||
ProxyBaseLLMRequestProcessing,
|
||||
ProxyConfig,
|
||||
_extract_error_from_sse_chunk,
|
||||
_get_cost_breakdown_from_logging_obj,
|
||||
_parse_event_data_for_error,
|
||||
create_streaming_response,
|
||||
create_response,
|
||||
)
|
||||
from litellm.proxy.utils import ProxyLogging
|
||||
|
||||
|
|
@ -75,6 +76,84 @@ class TestProxyBaseLLMRequestProcessing:
|
|||
pytest.fail("litellm_call_id is not a valid UUID")
|
||||
assert data_passed["litellm_call_id"] == returned_data["litellm_call_id"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_apply_hierarchical_router_settings_to_user_config(
|
||||
self, monkeypatch
|
||||
):
|
||||
processing_obj = ProxyBaseLLMRequestProcessing(data={})
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.headers = {}
|
||||
|
||||
async def mock_add_litellm_data_to_request(*args, **kwargs):
|
||||
return {}
|
||||
|
||||
async def mock_common_processing_pre_call_logic(
|
||||
user_api_key_dict, data, call_type
|
||||
):
|
||||
data_copy = copy.deepcopy(data)
|
||||
return data_copy
|
||||
|
||||
mock_proxy_logging_obj = MagicMock(spec=ProxyLogging)
|
||||
mock_proxy_logging_obj.pre_call_hook = AsyncMock(
|
||||
side_effect=mock_common_processing_pre_call_logic
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
litellm.proxy.common_request_processing,
|
||||
"add_litellm_data_to_request",
|
||||
mock_add_litellm_data_to_request,
|
||||
)
|
||||
|
||||
mock_general_settings = {}
|
||||
mock_user_api_key_dict = MagicMock(spec=UserAPIKeyAuth)
|
||||
mock_proxy_config = MagicMock(spec=ProxyConfig)
|
||||
|
||||
mock_router_settings = {
|
||||
"routing_strategy": "least-busy",
|
||||
"timeout": 30.0,
|
||||
"num_retries": 3,
|
||||
}
|
||||
mock_proxy_config._get_hierarchical_router_settings = AsyncMock(
|
||||
return_value=mock_router_settings
|
||||
)
|
||||
|
||||
mock_model_list = [
|
||||
{"model_name": "gpt-3.5-turbo", "litellm_params": {"model": "gpt-3.5-turbo"}},
|
||||
{"model_name": "gpt-4", "litellm_params": {"model": "gpt-4"}},
|
||||
]
|
||||
mock_llm_router = MagicMock()
|
||||
mock_llm_router.get_model_list = MagicMock(return_value=mock_model_list)
|
||||
|
||||
mock_prisma_client = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.proxy_server.prisma_client",
|
||||
mock_prisma_client,
|
||||
)
|
||||
|
||||
route_type = "acompletion"
|
||||
|
||||
returned_data, logging_obj = await processing_obj.common_processing_pre_call_logic(
|
||||
request=mock_request,
|
||||
general_settings=mock_general_settings,
|
||||
user_api_key_dict=mock_user_api_key_dict,
|
||||
proxy_logging_obj=mock_proxy_logging_obj,
|
||||
proxy_config=mock_proxy_config,
|
||||
route_type=route_type,
|
||||
llm_router=mock_llm_router,
|
||||
)
|
||||
|
||||
mock_proxy_config._get_hierarchical_router_settings.assert_called_once_with(
|
||||
user_api_key_dict=mock_user_api_key_dict,
|
||||
prisma_client=mock_prisma_client,
|
||||
)
|
||||
mock_llm_router.get_model_list.assert_called_once()
|
||||
|
||||
assert "user_config" in returned_data
|
||||
user_config = returned_data["user_config"]
|
||||
assert user_config["model_list"] == mock_model_list
|
||||
assert user_config["routing_strategy"] == "least-busy"
|
||||
assert user_config["timeout"] == 30.0
|
||||
assert user_config["num_retries"] == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_timeout_header_processing(self):
|
||||
"""
|
||||
|
|
@ -602,21 +681,27 @@ class TestCommonRequestProcessingHelpers:
|
|||
assert await _parse_event_data_for_error(event_line) == expected_code
|
||||
|
||||
async def test_create_streaming_response_first_chunk_is_error(self):
|
||||
"""
|
||||
Test that when the first chunk is an error, a JSON error response is returned
|
||||
instead of an SSE streaming response
|
||||
"""
|
||||
async def mock_generator():
|
||||
yield 'data: {"error": {"code": 403, "message": "forbidden"}}\n\n'
|
||||
yield 'data: {"content": "more data"}\n\n'
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
response = await create_streaming_response(
|
||||
response = await create_response(
|
||||
mock_generator(), "text/event-stream", {}
|
||||
)
|
||||
# Should return JSONResponse instead of StreamingResponse
|
||||
assert isinstance(response, JSONResponse)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
content = await self.consume_stream(response)
|
||||
assert content == [
|
||||
'data: {"error": {"code": 403, "message": "forbidden"}}\n\n',
|
||||
'data: {"content": "more data"}\n\n',
|
||||
"data: [DONE]\n\n",
|
||||
]
|
||||
# Verify the response is in standard JSON error format
|
||||
import json
|
||||
body = json.loads(response.body.decode())
|
||||
assert "error" in body
|
||||
assert body["error"]["code"] == 403
|
||||
assert body["error"]["message"] == "forbidden"
|
||||
|
||||
async def test_create_streaming_response_first_chunk_not_error(self):
|
||||
async def mock_generator():
|
||||
|
|
@ -624,7 +709,7 @@ class TestCommonRequestProcessingHelpers:
|
|||
yield 'data: {"content": "second part"}\n\n'
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
response = await create_streaming_response(
|
||||
response = await create_response(
|
||||
mock_generator(), "text/event-stream", {}
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
|
@ -641,7 +726,7 @@ class TestCommonRequestProcessingHelpers:
|
|||
yield
|
||||
# Implicitly raises StopAsyncIteration
|
||||
|
||||
response = await create_streaming_response(
|
||||
response = await create_response(
|
||||
mock_generator(), "text/event-stream", {}
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
|
@ -654,7 +739,7 @@ class TestCommonRequestProcessingHelpers:
|
|||
mock_gen = AsyncMock()
|
||||
mock_gen.__anext__.side_effect = StopAsyncIteration
|
||||
|
||||
response = await create_streaming_response(mock_gen, "text/event-stream", {})
|
||||
response = await create_response(mock_gen, "text/event-stream", {})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
content = await self.consume_stream(response)
|
||||
assert content == []
|
||||
|
|
@ -665,7 +750,7 @@ class TestCommonRequestProcessingHelpers:
|
|||
mock_gen = AsyncMock()
|
||||
mock_gen.__anext__.side_effect = ValueError("Test error from generator")
|
||||
|
||||
response = await create_streaming_response(mock_gen, "text/event-stream", {})
|
||||
response = await create_response(mock_gen, "text/event-stream", {})
|
||||
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
content = await self.consume_stream(response)
|
||||
expected_error_data = {
|
||||
|
|
@ -682,19 +767,24 @@ class TestCommonRequestProcessingHelpers:
|
|||
assert content[1] == "data: [DONE]\n\n"
|
||||
|
||||
async def test_create_streaming_response_first_chunk_error_string_code(self):
|
||||
"""
|
||||
Test that when the first chunk contains a string error code, a JSON error response is returned
|
||||
"""
|
||||
async def mock_generator():
|
||||
yield 'data: {"error": {"code": "429", "message": "too many requests"}}\n\n'
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
response = await create_streaming_response(
|
||||
response = await create_response(
|
||||
mock_generator(), "text/event-stream", {}
|
||||
)
|
||||
assert isinstance(response, JSONResponse)
|
||||
assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS
|
||||
content = await self.consume_stream(response)
|
||||
assert content == [
|
||||
'data: {"error": {"code": "429", "message": "too many requests"}}\n\n',
|
||||
"data: [DONE]\n\n",
|
||||
]
|
||||
# Verify the response is in standard JSON error format
|
||||
import json
|
||||
body = json.loads(response.body.decode())
|
||||
assert "error" in body
|
||||
assert body["error"]["code"] == "429"
|
||||
assert body["error"]["message"] == "too many requests"
|
||||
|
||||
async def test_create_streaming_response_custom_headers(self):
|
||||
async def mock_generator():
|
||||
|
|
@ -702,7 +792,7 @@ class TestCommonRequestProcessingHelpers:
|
|||
yield "data: [DONE]\n\n"
|
||||
|
||||
custom_headers = {"X-Custom-Header": "TestValue"}
|
||||
response = await create_streaming_response(
|
||||
response = await create_response(
|
||||
mock_generator(), "text/event-stream", custom_headers
|
||||
)
|
||||
assert response.headers["x-custom-header"] == "TestValue"
|
||||
|
|
@ -712,7 +802,7 @@ class TestCommonRequestProcessingHelpers:
|
|||
yield 'data: {"content": "data"}\n\n'
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
response = await create_streaming_response(
|
||||
response = await create_response(
|
||||
mock_generator(),
|
||||
"text/event-stream",
|
||||
{},
|
||||
|
|
@ -729,7 +819,7 @@ class TestCommonRequestProcessingHelpers:
|
|||
async def mock_generator():
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
response = await create_streaming_response(
|
||||
response = await create_response(
|
||||
mock_generator(), "text/event-stream", {}
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK # Default status
|
||||
|
|
@ -742,7 +832,7 @@ class TestCommonRequestProcessingHelpers:
|
|||
yield 'data: {"content": "actual data"}\n\n'
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
response = await create_streaming_response(
|
||||
response = await create_response(
|
||||
mock_generator(), "text/event-stream", {}
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK # Default status
|
||||
|
|
@ -773,7 +863,7 @@ class TestCommonRequestProcessingHelpers:
|
|||
|
||||
# Patch the tracer in the common_request_processing module
|
||||
with patch("litellm.proxy.common_request_processing.tracer", mock_tracer):
|
||||
response = await create_streaming_response(
|
||||
response = await create_response(
|
||||
mock_generator(), "text/event-stream", {}
|
||||
)
|
||||
|
||||
|
|
@ -810,7 +900,10 @@ class TestCommonRequestProcessingHelpers:
|
|||
), f"Call {i} should have operation name 'streaming.chunk.yield', got {args[0]}"
|
||||
|
||||
async def test_create_streaming_response_dd_trace_with_error_chunk(self):
|
||||
"""Test that dd trace is applied even when the first chunk contains an error"""
|
||||
"""
|
||||
Test that when the first chunk contains an error, JSONResponse is returned
|
||||
and tracing is not triggered (since it's not a streaming response)
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
# Create a mock tracer
|
||||
|
|
@ -827,28 +920,107 @@ class TestCommonRequestProcessingHelpers:
|
|||
|
||||
# Patch the tracer in the common_request_processing module
|
||||
with patch("litellm.proxy.common_request_processing.tracer", mock_tracer):
|
||||
response = await create_streaming_response(
|
||||
response = await create_response(
|
||||
mock_generator(), "text/event-stream", {}
|
||||
)
|
||||
|
||||
# Even with error, status should be set to error code but tracing should still work
|
||||
# Should return JSONResponse instead of StreamingResponse
|
||||
assert isinstance(response, JSONResponse)
|
||||
assert response.status_code == 400
|
||||
|
||||
# Consume the stream to trigger the tracer calls
|
||||
content = await self.consume_stream(response)
|
||||
# Verify the response is in standard JSON error format
|
||||
import json
|
||||
body = json.loads(response.body.decode())
|
||||
assert "error" in body
|
||||
assert body["error"]["code"] == 400
|
||||
assert body["error"]["message"] == "bad request"
|
||||
|
||||
# Verify all chunks are present
|
||||
assert len(content) == 3
|
||||
# Since JSONResponse is returned instead of StreamingResponse, streaming tracing should not be triggered
|
||||
# tracer.trace should not be called
|
||||
assert mock_tracer.trace.call_count == 0
|
||||
|
||||
# Verify that tracer.trace was called for each chunk
|
||||
assert mock_tracer.trace.call_count == 3
|
||||
|
||||
# Verify that each call was made with the correct operation name
|
||||
actual_calls = mock_tracer.trace.call_args_list
|
||||
assert len(actual_calls) == 3
|
||||
class TestExtractErrorFromSSEChunk:
|
||||
"""Tests for _extract_error_from_sse_chunk function"""
|
||||
|
||||
def test_extract_error_from_sse_chunk_with_valid_error(self):
|
||||
"""Test extracting error information from a standard SSE chunk"""
|
||||
chunk = 'data: {"error": {"code": 403, "message": "forbidden", "type": "auth_error", "param": "api_key"}}\n\n'
|
||||
error = _extract_error_from_sse_chunk(chunk)
|
||||
|
||||
assert error["code"] == 403
|
||||
assert error["message"] == "forbidden"
|
||||
assert error["type"] == "auth_error"
|
||||
assert error["param"] == "api_key"
|
||||
|
||||
def test_extract_error_from_sse_chunk_with_string_code(self):
|
||||
"""Test error code as string type"""
|
||||
chunk = 'data: {"error": {"code": "429", "message": "too many requests"}}\n\n'
|
||||
error = _extract_error_from_sse_chunk(chunk)
|
||||
|
||||
assert error["code"] == "429"
|
||||
assert error["message"] == "too many requests"
|
||||
|
||||
def test_extract_error_from_sse_chunk_with_bytes(self):
|
||||
"""Test input as bytes type"""
|
||||
chunk = b'data: {"error": {"code": 500, "message": "internal error"}}\n\n'
|
||||
error = _extract_error_from_sse_chunk(chunk)
|
||||
|
||||
assert error["code"] == 500
|
||||
assert error["message"] == "internal error"
|
||||
|
||||
def test_extract_error_from_sse_chunk_with_done(self):
|
||||
"""Test [DONE] marker should return default error"""
|
||||
chunk = "data: [DONE]\n\n"
|
||||
error = _extract_error_from_sse_chunk(chunk)
|
||||
|
||||
assert error["message"] == "Unknown error"
|
||||
assert error["type"] == "internal_server_error"
|
||||
assert error["code"] == "500"
|
||||
assert error["param"] is None
|
||||
|
||||
def test_extract_error_from_sse_chunk_without_error_field(self):
|
||||
"""Test missing error field should return default error"""
|
||||
chunk = 'data: {"content": "some content"}\n\n'
|
||||
error = _extract_error_from_sse_chunk(chunk)
|
||||
|
||||
assert error["message"] == "Unknown error"
|
||||
assert error["type"] == "internal_server_error"
|
||||
assert error["code"] == "500"
|
||||
|
||||
def test_extract_error_from_sse_chunk_with_invalid_json(self):
|
||||
"""Test invalid JSON should return default error"""
|
||||
chunk = 'data: {invalid json}\n\n'
|
||||
error = _extract_error_from_sse_chunk(chunk)
|
||||
|
||||
assert error["message"] == "Unknown error"
|
||||
assert error["type"] == "internal_server_error"
|
||||
assert error["code"] == "500"
|
||||
|
||||
def test_extract_error_from_sse_chunk_without_data_prefix(self):
|
||||
"""Test missing 'data:' prefix should return default error"""
|
||||
chunk = '{"error": {"code": 400, "message": "bad request"}}\n\n'
|
||||
error = _extract_error_from_sse_chunk(chunk)
|
||||
|
||||
assert error["message"] == "Unknown error"
|
||||
assert error["type"] == "internal_server_error"
|
||||
assert error["code"] == "500"
|
||||
|
||||
def test_extract_error_from_sse_chunk_with_empty_string(self):
|
||||
"""Test empty string should return default error"""
|
||||
chunk = ""
|
||||
error = _extract_error_from_sse_chunk(chunk)
|
||||
|
||||
assert error["message"] == "Unknown error"
|
||||
assert error["type"] == "internal_server_error"
|
||||
assert error["code"] == "500"
|
||||
|
||||
def test_extract_error_from_sse_chunk_with_minimal_error(self):
|
||||
"""Test minimal error object"""
|
||||
chunk = 'data: {"error": {"message": "error occurred"}}\n\n'
|
||||
error = _extract_error_from_sse_chunk(chunk)
|
||||
|
||||
assert error["message"] == "error occurred"
|
||||
# Other fields should be obtained from the original error object (if exists)
|
||||
|
||||
|
||||
for i, call in enumerate(actual_calls):
|
||||
args, kwargs = call
|
||||
assert (
|
||||
args[0] == "streaming.chunk.yield"
|
||||
), f"Call {i} should have operation name 'streaming.chunk.yield', got {args[0]}"
|
||||
|
|
|
|||
|
|
@ -3124,3 +3124,95 @@ def test_deep_merge_dicts_skips_none_and_empty_lists(monkeypatch):
|
|||
assert result["general_settings"]["nested"]["key1"] == "updated_value1"
|
||||
assert result["general_settings"]["nested"]["key2"] == "value2"
|
||||
assert result["general_settings"]["nested"]["key3"] == "value3"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_hierarchical_router_settings():
|
||||
"""
|
||||
Test _get_hierarchical_router_settings method's priority order: Key > Team > Global
|
||||
"""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.proxy_server import ProxyConfig
|
||||
|
||||
proxy_config = ProxyConfig()
|
||||
|
||||
# Test Case 1: Returns None when prisma_client is None
|
||||
result = await proxy_config._get_hierarchical_router_settings(
|
||||
user_api_key_dict=None,
|
||||
prisma_client=None,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
# Test Case 2: Returns key-level router_settings when available (as dict)
|
||||
mock_user_api_key_dict = MagicMock(spec=UserAPIKeyAuth)
|
||||
mock_user_api_key_dict.router_settings = {"routing_strategy": "key-level", "timeout": 10}
|
||||
mock_user_api_key_dict.team_id = None
|
||||
|
||||
mock_prisma_client = MagicMock()
|
||||
|
||||
result = await proxy_config._get_hierarchical_router_settings(
|
||||
user_api_key_dict=mock_user_api_key_dict,
|
||||
prisma_client=mock_prisma_client,
|
||||
)
|
||||
assert result == {"routing_strategy": "key-level", "timeout": 10}
|
||||
|
||||
# Test Case 3: Returns key-level router_settings when available (as YAML string)
|
||||
mock_user_api_key_dict.router_settings = "routing_strategy: key-yaml\ntimeout: 20"
|
||||
result = await proxy_config._get_hierarchical_router_settings(
|
||||
user_api_key_dict=mock_user_api_key_dict,
|
||||
prisma_client=mock_prisma_client,
|
||||
)
|
||||
assert result == {"routing_strategy": "key-yaml", "timeout": 20}
|
||||
|
||||
# Test Case 4: Falls back to team-level router_settings when key-level is not available
|
||||
mock_user_api_key_dict.router_settings = None
|
||||
mock_user_api_key_dict.team_id = "team-123"
|
||||
|
||||
mock_team_obj = MagicMock()
|
||||
mock_team_obj.router_settings = {"routing_strategy": "team-level", "timeout": 30}
|
||||
|
||||
mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(
|
||||
return_value=mock_team_obj
|
||||
)
|
||||
|
||||
result = await proxy_config._get_hierarchical_router_settings(
|
||||
user_api_key_dict=mock_user_api_key_dict,
|
||||
prisma_client=mock_prisma_client,
|
||||
)
|
||||
assert result == {"routing_strategy": "team-level", "timeout": 30}
|
||||
mock_prisma_client.db.litellm_teamtable.find_unique.assert_called_once_with(
|
||||
where={"team_id": "team-123"}
|
||||
)
|
||||
|
||||
# Test Case 5: Falls back to global router_settings when neither key nor team settings are available
|
||||
mock_user_api_key_dict.router_settings = None
|
||||
mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=None)
|
||||
|
||||
mock_db_config = MagicMock()
|
||||
mock_db_config.param_value = {"routing_strategy": "global-level", "timeout": 40}
|
||||
|
||||
mock_prisma_client.db.litellm_config.find_first = AsyncMock(
|
||||
return_value=mock_db_config
|
||||
)
|
||||
|
||||
result = await proxy_config._get_hierarchical_router_settings(
|
||||
user_api_key_dict=mock_user_api_key_dict,
|
||||
prisma_client=mock_prisma_client,
|
||||
)
|
||||
assert result == {"routing_strategy": "global-level", "timeout": 40}
|
||||
mock_prisma_client.db.litellm_config.find_first.assert_called_once_with(
|
||||
where={"param_name": "router_settings"}
|
||||
)
|
||||
|
||||
# Test Case 6: Returns None when no settings are found
|
||||
mock_user_api_key_dict.router_settings = None
|
||||
mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=None)
|
||||
mock_prisma_client.db.litellm_config.find_first = AsyncMock(return_value=None)
|
||||
|
||||
result = await proxy_config._get_hierarchical_router_settings(
|
||||
user_api_key_dict=mock_user_api_key_dict,
|
||||
prisma_client=mock_prisma_client,
|
||||
)
|
||||
assert result is None
|
||||
|
|
|
|||
|
|
@ -203,3 +203,22 @@ class TestResponseAPILoggingUtils:
|
|||
assert result.prompt_tokens == 0
|
||||
assert result.completion_tokens == 20
|
||||
assert result.total_tokens == 20
|
||||
|
||||
def test_transform_response_api_usage_calculates_total_from_input_and_output_tokens_if_available(self):
|
||||
"""Test transformation calculates total_tokens when it's None and input / output tokens are present"""
|
||||
# Setup
|
||||
usage = {
|
||||
"input_tokens": 15,
|
||||
"output_tokens": 25,
|
||||
"total_tokens": None,
|
||||
}
|
||||
|
||||
# Execute
|
||||
result = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(
|
||||
usage
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.prompt_tokens == 15
|
||||
assert result.completion_tokens == 25
|
||||
assert result.total_tokens == 40 # 15 + 25
|
||||
|
|
|
|||
42
tests/test_litellm/test_utils_custom.py
Normal file
42
tests/test_litellm/test_utils_custom.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import pytest
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
from litellm.proxy.utils import count_tokens_with_anthropic_api, _anthropic_async_clients
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_count_tokens_caching():
|
||||
"""
|
||||
Test that count_tokens_with_anthropic_api caches the client.
|
||||
"""
|
||||
# Clear cache
|
||||
_anthropic_async_clients.clear()
|
||||
|
||||
api_key = "sk-ant-test-key"
|
||||
messages = [{"role": "user", "content": "hello"}]
|
||||
model = "claude-3-opus-20240229"
|
||||
|
||||
# Mock anthropic
|
||||
with patch("anthropic.AsyncAnthropic") as mock_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_cls.return_value = mock_client
|
||||
|
||||
# Mock response
|
||||
mock_response = MagicMock()
|
||||
mock_response.input_tokens = 10
|
||||
|
||||
# Setup async return for count_tokens
|
||||
mock_client.beta.messages.count_tokens = AsyncMock(return_value=mock_response)
|
||||
|
||||
# First call
|
||||
with patch.dict("os.environ", {"ANTHROPIC_API_KEY": api_key}):
|
||||
await count_tokens_with_anthropic_api(model, messages)
|
||||
|
||||
assert api_key in _anthropic_async_clients
|
||||
assert _anthropic_async_clients[api_key] == mock_client
|
||||
mock_cls.assert_called_once() # Should be called once
|
||||
|
||||
# Second call
|
||||
with patch.dict("os.environ", {"ANTHROPIC_API_KEY": api_key}):
|
||||
await count_tokens_with_anthropic_api(model, messages)
|
||||
|
||||
# Should still be called once (cached)
|
||||
mock_cls.assert_called_once()
|
||||
38
ui/litellm-dashboard/e2e_tests/fixtures/menuMappings.ts
Normal file
38
ui/litellm-dashboard/e2e_tests/fixtures/menuMappings.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { Page } from "./pages";
|
||||
|
||||
/**
|
||||
* Maps sidebar menu item labels to their corresponding page enum values.
|
||||
* This mapping is for the admin role.
|
||||
*/
|
||||
export const menuLabelToPage: Record<string, Page> = {
|
||||
"Virtual Keys": Page.ApiKeys,
|
||||
Playground: Page.LlmPlayground,
|
||||
Models: Page.Models,
|
||||
"Models + Endpoints": Page.Models,
|
||||
Usage: Page.NewUsage,
|
||||
Teams: Page.Teams,
|
||||
"Internal Users": Page.Users,
|
||||
"Internal User": Page.Users, // Legacy label support
|
||||
Organizations: Page.Organizations,
|
||||
"API Reference": Page.ApiRef,
|
||||
"AI Hub": Page.ModelHubTable,
|
||||
"Model Hub": Page.ModelHubTable,
|
||||
Logs: Page.Logs,
|
||||
Guardrails: Page.Guardrails,
|
||||
// Settings submenu items
|
||||
"Router Settings": Page.RouterSettings,
|
||||
"Logging & Alerts": Page.LoggingAndAlerts,
|
||||
"Admin Settings": Page.AdminPanel,
|
||||
"Cost Tracking": Page.CostTracking,
|
||||
"UI Theme": Page.UiTheme,
|
||||
// Experimental submenu items
|
||||
Caching: Page.Caching,
|
||||
Prompts: Page.Prompts,
|
||||
Budgets: Page.Budgets,
|
||||
"API Playground": Page.TransformRequest,
|
||||
"Tag Management": Page.TagManagement,
|
||||
"Old Usage": Page.Usage,
|
||||
// Tools submenu items
|
||||
"MCP Servers": Page.McpServers,
|
||||
"Vector Stores": Page.VectorStores,
|
||||
};
|
||||
33
ui/litellm-dashboard/e2e_tests/fixtures/pages.ts
Normal file
33
ui/litellm-dashboard/e2e_tests/fixtures/pages.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* Enum for all page query parameters supported in the app.
|
||||
* These values correspond to the `page` query parameter used in the URL.
|
||||
*/
|
||||
export enum Page {
|
||||
ApiKeys = "api-keys",
|
||||
Models = "models",
|
||||
LlmPlayground = "llm-playground",
|
||||
Users = "users",
|
||||
Teams = "teams",
|
||||
Organizations = "organizations",
|
||||
AdminPanel = "admin-panel",
|
||||
ApiRef = "api_ref",
|
||||
LoggingAndAlerts = "logging-and-alerts",
|
||||
Budgets = "budgets",
|
||||
Guardrails = "guardrails",
|
||||
Agents = "agents",
|
||||
Prompts = "prompts",
|
||||
TransformRequest = "transform-request",
|
||||
RouterSettings = "router-settings",
|
||||
UiTheme = "ui-theme",
|
||||
CostTracking = "cost-tracking",
|
||||
ModelHubTable = "model-hub-table",
|
||||
Caching = "caching",
|
||||
PassThroughSettings = "pass-through-settings",
|
||||
Logs = "logs",
|
||||
McpServers = "mcp-servers",
|
||||
SearchTools = "search-tools",
|
||||
TagManagement = "tag-management",
|
||||
VectorStores = "vector-stores",
|
||||
NewUsage = "new_usage",
|
||||
Usage = "usage",
|
||||
}
|
||||
12
ui/litellm-dashboard/e2e_tests/helpers/navigation.ts
Normal file
12
ui/litellm-dashboard/e2e_tests/helpers/navigation.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { Page } from "../fixtures/pages";
|
||||
import { Page as PlaywrightPage } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Navigates to a specific page using the page query parameter.
|
||||
* Uses relative path which will be resolved against the baseURL configured in playwright.config.ts
|
||||
* @param page - The Playwright page object
|
||||
* @param pageEnum - The page enum value to navigate to
|
||||
*/
|
||||
export async function navigateToPage(page: PlaywrightPage, pageEnum: Page): Promise<void> {
|
||||
await page.goto(`/ui?page=${pageEnum}`);
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@ test.describe("Add Model", () => {
|
|||
await providerInputDropdown.fill("Anthropic");
|
||||
await page.waitForTimeout(1000);
|
||||
await providerInputDropdown.press("Enter");
|
||||
await page.waitForTimeout(1000);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const providerModelsDropdown = page.locator(".ant-select-selection-overflow").first();
|
||||
await providerModelsDropdown.click();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import test, { expect } from "@playwright/test";
|
||||
import { Role } from "../../fixtures/roles";
|
||||
import { ADMIN_STORAGE_PATH } from "../../constants";
|
||||
import { Page } from "../../fixtures/pages";
|
||||
import { menuLabelToPage } from "../../fixtures/menuMappings";
|
||||
import { navigateToPage } from "../../helpers/navigation";
|
||||
|
||||
const sidebarButtons = {
|
||||
[Role.ProxyAdmin]: [
|
||||
|
|
@ -9,9 +12,7 @@ const sidebarButtons = {
|
|||
"Models",
|
||||
"Usage",
|
||||
"Teams",
|
||||
"Internal User",
|
||||
"Settings",
|
||||
"Experimental",
|
||||
"Internal Users",
|
||||
"API Reference",
|
||||
"AI Hub",
|
||||
],
|
||||
|
|
@ -23,13 +24,36 @@ for (const { role, storage } of roles) {
|
|||
test.describe(`${role} sidebar`, () => {
|
||||
test.use({ storageState: storage });
|
||||
|
||||
test("can see and navigate all sidebar buttons", async ({ page }) => {
|
||||
test("should navigate to correct URL when clicking sidebar menu items from homepage", async ({ page }) => {
|
||||
await page.goto("/ui");
|
||||
for (const button of sidebarButtons[role as keyof typeof sidebarButtons]) {
|
||||
const tab = page.getByRole("menuitem", { name: button });
|
||||
|
||||
for (const buttonLabel of sidebarButtons[role as keyof typeof sidebarButtons]) {
|
||||
const expectedPage = menuLabelToPage[buttonLabel];
|
||||
|
||||
if (!expectedPage) {
|
||||
throw new Error(`No page mapping found for menu label: ${buttonLabel}`);
|
||||
}
|
||||
|
||||
const tab = page.getByRole("menuitem", { name: buttonLabel });
|
||||
await expect(tab).toBeVisible();
|
||||
|
||||
await tab.click();
|
||||
|
||||
// Verify URL contains the correct page query parameter
|
||||
await expect(page).toHaveURL(new RegExp(`[?&]page=${expectedPage}(&|$)`));
|
||||
}
|
||||
});
|
||||
|
||||
test("should navigate directly to page using navigation helper", async ({ page }) => {
|
||||
// Test direct navigation to verify the helper function works
|
||||
await navigateToPage(page, Page.ApiKeys);
|
||||
await expect(page).toHaveURL(new RegExp(`[?&]page=${Page.ApiKeys}(&|$)`));
|
||||
|
||||
await navigateToPage(page, Page.Models);
|
||||
await expect(page).toHaveURL(new RegExp(`[?&]page=${Page.Models}(&|$)`));
|
||||
|
||||
await navigateToPage(page, Page.LlmPlayground);
|
||||
await expect(page).toHaveURL(new RegExp(`[?&]page=${Page.LlmPlayground}(&|$)`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,187 @@
|
|||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
convertImageToBase64,
|
||||
createChatMultimodalMessage,
|
||||
createChatDisplayMessage,
|
||||
shouldShowChatAttachedImage,
|
||||
} from "./ChatImageUtils";
|
||||
import { MessageType } from "./types";
|
||||
|
||||
describe("ChatImageUtils", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("convertImageToBase64", () => {
|
||||
it("should convert file to base64 data URI", async () => {
|
||||
const file = new File(["test content"], "test.png", { type: "image/png" });
|
||||
const result = await convertImageToBase64(file);
|
||||
expect(result).toMatch(/^data:image\/png;base64,/);
|
||||
});
|
||||
|
||||
it("should handle different file types", async () => {
|
||||
const jpegFile = new File(["jpeg content"], "test.jpg", { type: "image/jpeg" });
|
||||
const result = await convertImageToBase64(jpegFile);
|
||||
expect(result).toMatch(/^data:image\/jpeg;base64,/);
|
||||
});
|
||||
|
||||
it("should reject on file read error", async () => {
|
||||
const file = new File(["test"], "test.png", { type: "image/png" });
|
||||
const originalReadAsDataURL = FileReader.prototype.readAsDataURL;
|
||||
|
||||
FileReader.prototype.readAsDataURL = vi.fn(function (this: FileReader) {
|
||||
setTimeout(() => {
|
||||
if (this.onerror) {
|
||||
this.onerror(new Error("Read error") as any);
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
|
||||
await expect(convertImageToBase64(file)).rejects.toThrow();
|
||||
|
||||
FileReader.prototype.readAsDataURL = originalReadAsDataURL;
|
||||
});
|
||||
});
|
||||
|
||||
describe("createChatMultimodalMessage", () => {
|
||||
it("should create multimodal message with text and image", async () => {
|
||||
const file = new File(["test content"], "test.png", { type: "image/png" });
|
||||
const inputMessage = "What is in this image?";
|
||||
|
||||
const result = await createChatMultimodalMessage(inputMessage, file);
|
||||
|
||||
expect(result.role).toBe("user");
|
||||
expect(result.content).toHaveLength(2);
|
||||
expect(result.content[0]).toEqual({ type: "text", text: inputMessage });
|
||||
expect(result.content[1]).toMatchObject({
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: expect.stringMatching(/^data:image\/png;base64,/),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should include base64 data URI in image_url", async () => {
|
||||
const file = new File(["test content"], "test.png", { type: "image/png" });
|
||||
const result = await createChatMultimodalMessage("test", file);
|
||||
|
||||
const imageContent = result.content[1];
|
||||
expect(imageContent.type).toBe("image_url");
|
||||
if ("image_url" in imageContent && imageContent.image_url) {
|
||||
expect(imageContent.image_url.url).toMatch(/^data:/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createChatDisplayMessage", () => {
|
||||
it("should create display message without file", () => {
|
||||
const result = createChatDisplayMessage("Hello world", false);
|
||||
|
||||
expect(result.role).toBe("user");
|
||||
expect(result.content).toBe("Hello world");
|
||||
expect(result.imagePreviewUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should create display message with PDF file", () => {
|
||||
const filePreviewUrl = "blob:test-url";
|
||||
const result = createChatDisplayMessage("Read this", true, filePreviewUrl, "document.pdf");
|
||||
|
||||
expect(result.content).toBe("Read this [PDF attached]");
|
||||
expect(result.imagePreviewUrl).toBe(filePreviewUrl);
|
||||
});
|
||||
|
||||
it("should create display message with image file", () => {
|
||||
const filePreviewUrl = "blob:test-url";
|
||||
const result = createChatDisplayMessage("Look at this", true, filePreviewUrl, "photo.jpg");
|
||||
|
||||
expect(result.content).toBe("Look at this [Image attached]");
|
||||
expect(result.imagePreviewUrl).toBe(filePreviewUrl);
|
||||
});
|
||||
|
||||
it("should create display message with file but no fileName", () => {
|
||||
const filePreviewUrl = "blob:test-url";
|
||||
const result = createChatDisplayMessage("Check this", true, filePreviewUrl);
|
||||
|
||||
expect(result.content).toBe("Check this ");
|
||||
expect(result.imagePreviewUrl).toBe(filePreviewUrl);
|
||||
});
|
||||
|
||||
it("should create display message with file but no preview URL", () => {
|
||||
const result = createChatDisplayMessage("See this", true, undefined, "image.png");
|
||||
|
||||
expect(result.content).toBe("See this [Image attached]");
|
||||
expect(result.imagePreviewUrl).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldShowChatAttachedImage", () => {
|
||||
it("should return true for user message with image attachment", () => {
|
||||
const message: MessageType = {
|
||||
role: "user",
|
||||
content: "Check this [Image attached]",
|
||||
imagePreviewUrl: "blob:test-url",
|
||||
};
|
||||
|
||||
expect(shouldShowChatAttachedImage(message)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for user message with PDF attachment", () => {
|
||||
const message: MessageType = {
|
||||
role: "user",
|
||||
content: "Read this [PDF attached]",
|
||||
imagePreviewUrl: "blob:test-url",
|
||||
};
|
||||
|
||||
expect(shouldShowChatAttachedImage(message)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for assistant message", () => {
|
||||
const message: MessageType = {
|
||||
role: "assistant",
|
||||
content: "Here is the image [Image attached]",
|
||||
imagePreviewUrl: "blob:test-url",
|
||||
};
|
||||
|
||||
expect(shouldShowChatAttachedImage(message)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when content is not a string", () => {
|
||||
const message: MessageType = {
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "test" }],
|
||||
imagePreviewUrl: "blob:test-url",
|
||||
};
|
||||
|
||||
expect(shouldShowChatAttachedImage(message)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when content does not include attachment marker", () => {
|
||||
const message: MessageType = {
|
||||
role: "user",
|
||||
content: "Just regular text",
|
||||
imagePreviewUrl: "blob:test-url",
|
||||
};
|
||||
|
||||
expect(shouldShowChatAttachedImage(message)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when imagePreviewUrl is missing", () => {
|
||||
const message: MessageType = {
|
||||
role: "user",
|
||||
content: "Check this [Image attached]",
|
||||
};
|
||||
|
||||
expect(shouldShowChatAttachedImage(message)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when imagePreviewUrl is empty string", () => {
|
||||
const message: MessageType = {
|
||||
role: "user",
|
||||
content: "Check this [Image attached]",
|
||||
imagePreviewUrl: "",
|
||||
};
|
||||
|
||||
expect(shouldShowChatAttachedImage(message)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -84,10 +84,7 @@ interface ChatUIProps {
|
|||
};
|
||||
}
|
||||
|
||||
const MCP_SUPPORTED_ENDPOINTS = new Set<EndpointType>([
|
||||
EndpointType.CHAT,
|
||||
EndpointType.RESPONSES,
|
||||
]);
|
||||
const MCP_SUPPORTED_ENDPOINTS = new Set<EndpointType>([EndpointType.CHAT, EndpointType.RESPONSES]);
|
||||
|
||||
const ChatUI: React.FC<ChatUIProps> = ({
|
||||
accessToken,
|
||||
|
|
@ -131,7 +128,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
|
|||
});
|
||||
const [apiKey, setApiKey] = useState<string>(() => sessionStorage.getItem("apiKey") || "");
|
||||
const [customProxyBaseUrl, setCustomProxyBaseUrl] = useState<string>(
|
||||
() => sessionStorage.getItem("customProxyBaseUrl") || ""
|
||||
() => sessionStorage.getItem("customProxyBaseUrl") || "",
|
||||
);
|
||||
const [inputMessage, setInputMessage] = useState("");
|
||||
const [chatHistory, setChatHistory] = useState<MessageType[]>(() => {
|
||||
|
|
@ -215,7 +212,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
|
|||
const [temperature, setTemperature] = useState<number>(1.0);
|
||||
const [maxTokens, setMaxTokens] = useState<number>(2048);
|
||||
const [useAdvancedParams, setUseAdvancedParams] = useState<boolean>(false);
|
||||
|
||||
|
||||
// Code Interpreter state (using custom hook)
|
||||
const codeInterpreter = useCodeInterpreter();
|
||||
|
||||
|
|
@ -244,16 +241,15 @@ const ChatUI: React.FC<ChatUIProps> = ({
|
|||
|
||||
try {
|
||||
const response = await listMCPTools(userApiKey, serverId);
|
||||
setServerToolsMap(prev => ({
|
||||
setServerToolsMap((prev) => ({
|
||||
...prev,
|
||||
[serverId]: response.tools || []
|
||||
[serverId]: response.tools || [],
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(`Error fetching tools for server ${serverId}:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (isGetCodeModalVisible) {
|
||||
const code = generateCodeSnippet({
|
||||
|
|
@ -1007,7 +1003,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
|
|||
traceId,
|
||||
selectedVectorStores.length > 0 ? selectedVectorStores : undefined,
|
||||
selectedGuardrails.length > 0 ? selectedGuardrails : undefined,
|
||||
selectedMCPTools, // Pass the selected tools array
|
||||
selectedMCPServers, // Pass the selected tools array
|
||||
customProxyBaseUrl || undefined,
|
||||
);
|
||||
} else if (endpointType === EndpointType.EMBEDDINGS) {
|
||||
|
|
@ -1205,9 +1201,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
|
|||
icon={ApiOutlined}
|
||||
/>
|
||||
{customProxyBaseUrl && (
|
||||
<Text className="text-xs text-gray-500 mt-1">
|
||||
API calls will be sent to: {customProxyBaseUrl}
|
||||
</Text>
|
||||
<Text className="text-xs text-gray-500 mt-1">API calls will be sent to: {customProxyBaseUrl}</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -1382,7 +1376,11 @@ const ChatUI: React.FC<ChatUIProps> = ({
|
|||
optionLabelProp="label"
|
||||
>
|
||||
{agentInfo.map((agent) => (
|
||||
<Select.Option key={agent.agent_id} value={agent.agent_name} label={agent.agent_name || agent.agent_id}>
|
||||
<Select.Option
|
||||
key={agent.agent_id}
|
||||
value={agent.agent_name}
|
||||
label={agent.agent_name || agent.agent_id}
|
||||
>
|
||||
<div className="flex flex-col py-1">
|
||||
<span className="font-medium">{agent.agent_name || agent.agent_id}</span>
|
||||
{agent.agent_card_params?.description && (
|
||||
|
|
@ -1416,10 +1414,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
|
|||
<div>
|
||||
<Text className="font-medium block mb-2 text-gray-700 flex items-center">
|
||||
<ToolOutlined className="mr-2" /> MCP Servers
|
||||
<Tooltip
|
||||
className="ml-1"
|
||||
title="Select MCP servers to use in your conversation."
|
||||
>
|
||||
<Tooltip className="ml-1" title="Select MCP servers to use in your conversation.">
|
||||
<InfoCircleOutlined />
|
||||
</Tooltip>
|
||||
</Text>
|
||||
|
|
@ -1435,15 +1430,15 @@ const ChatUI: React.FC<ChatUIProps> = ({
|
|||
} else {
|
||||
setSelectedMCPServers(value);
|
||||
// Clean up tool restrictions for removed servers
|
||||
setMCPServerToolRestrictions(prev => {
|
||||
setMCPServerToolRestrictions((prev) => {
|
||||
const updated = { ...prev };
|
||||
Object.keys(updated).forEach(serverId => {
|
||||
Object.keys(updated).forEach((serverId) => {
|
||||
if (!value.includes(serverId)) delete updated[serverId];
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
// Load tools for newly selected servers
|
||||
value.forEach(serverId => {
|
||||
value.forEach((serverId) => {
|
||||
if (!serverToolsMap[serverId]) {
|
||||
loadServerTools(serverId);
|
||||
}
|
||||
|
|
@ -1474,12 +1469,8 @@ const ChatUI: React.FC<ChatUIProps> = ({
|
|||
disabled={selectedMCPServers.includes("__all__")}
|
||||
>
|
||||
<div className="flex flex-col py-1">
|
||||
<span className="font-medium">
|
||||
{server.alias || server.server_name || server.server_id}
|
||||
</span>
|
||||
{server.description && (
|
||||
<span className="text-xs text-gray-500 mt-1">{server.description}</span>
|
||||
)}
|
||||
<span className="font-medium">{server.alias || server.server_name || server.server_id}</span>
|
||||
{server.description && <span className="text-xs text-gray-500 mt-1">{server.description}</span>}
|
||||
</div>
|
||||
</Select.Option>
|
||||
))}
|
||||
|
|
@ -1489,40 +1480,40 @@ const ChatUI: React.FC<ChatUIProps> = ({
|
|||
{selectedMCPServers.length > 0 &&
|
||||
!selectedMCPServers.includes("__all__") &&
|
||||
MCP_SUPPORTED_ENDPOINTS.has(endpointType as EndpointType) && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{selectedMCPServers.map(serverId => {
|
||||
const server = mcpServers.find(s => s.server_id === serverId);
|
||||
const tools = serverToolsMap[serverId] || [];
|
||||
if (tools.length === 0) return null;
|
||||
<div className="mt-3 space-y-2">
|
||||
{selectedMCPServers.map((serverId) => {
|
||||
const server = mcpServers.find((s) => s.server_id === serverId);
|
||||
const tools = serverToolsMap[serverId] || [];
|
||||
if (tools.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={serverId} className="border rounded p-2">
|
||||
<Text className="text-xs text-gray-600 mb-1">
|
||||
Limit tools for {server?.alias || server?.server_name || serverId}:
|
||||
</Text>
|
||||
<Select
|
||||
mode="multiple"
|
||||
size="small"
|
||||
style={{ width: "100%" }}
|
||||
placeholder="All tools (default)"
|
||||
value={mcpServerToolRestrictions[serverId] || []}
|
||||
onChange={(selectedTools) => {
|
||||
setMCPServerToolRestrictions(prev => ({
|
||||
...prev,
|
||||
[serverId]: selectedTools
|
||||
}));
|
||||
}}
|
||||
options={tools.map(tool => ({
|
||||
value: tool.name,
|
||||
label: tool.name
|
||||
}))}
|
||||
maxTagCount={2}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<div key={serverId} className="border rounded p-2">
|
||||
<Text className="text-xs text-gray-600 mb-1">
|
||||
Limit tools for {server?.alias || server?.server_name || serverId}:
|
||||
</Text>
|
||||
<Select
|
||||
mode="multiple"
|
||||
size="small"
|
||||
style={{ width: "100%" }}
|
||||
placeholder="All tools (default)"
|
||||
value={mcpServerToolRestrictions[serverId] || []}
|
||||
onChange={(selectedTools) => {
|
||||
setMCPServerToolRestrictions((prev) => ({
|
||||
...prev,
|
||||
[serverId]: selectedTools,
|
||||
}));
|
||||
}}
|
||||
options={tools.map((tool) => ({
|
||||
value: tool.name,
|
||||
label: tool.name,
|
||||
}))}
|
||||
maxTagCount={2}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
|
@ -2073,7 +2064,13 @@ const ChatUI: React.FC<ChatUIProps> = ({
|
|||
)}
|
||||
{/* Quick Code Interpreter toggle for Responses */}
|
||||
{endpointType === EndpointType.RESPONSES && (
|
||||
<Tooltip title={codeInterpreter.enabled ? "Code Interpreter enabled (click to disable)" : "Enable Code Interpreter"}>
|
||||
<Tooltip
|
||||
title={
|
||||
codeInterpreter.enabled
|
||||
? "Code Interpreter enabled (click to disable)"
|
||||
: "Enable Code Interpreter"
|
||||
}
|
||||
>
|
||||
<button
|
||||
className={`p-1.5 rounded-md transition-colors ${
|
||||
codeInterpreter.enabled
|
||||
|
|
|
|||
|
|
@ -0,0 +1,326 @@
|
|||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import CodeInterpreterOutput from "./CodeInterpreterOutput";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
getProxyBaseUrl: vi.fn(() => "https://example.com"),
|
||||
}));
|
||||
|
||||
global.fetch = vi.fn();
|
||||
|
||||
describe("CodeInterpreterOutput", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
URL.createObjectURL = vi.fn((blob) => `blob:${blob}`);
|
||||
URL.revokeObjectURL = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should render", () => {
|
||||
render(<CodeInterpreterOutput code="print('hello')" accessToken="test-token" />);
|
||||
|
||||
expect(screen.getByText("Python Code Executed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display code in syntax highlighter", async () => {
|
||||
const user = userEvent.setup();
|
||||
const code = "print('hello world')";
|
||||
const { container } = render(<CodeInterpreterOutput code={code} accessToken="test-token" />);
|
||||
|
||||
expect(screen.getByText("Python Code Executed")).toBeInTheDocument();
|
||||
|
||||
const collapseHeader = screen.getByRole("button");
|
||||
await user.click(collapseHeader);
|
||||
|
||||
await waitFor(() => {
|
||||
const codeElement = container.querySelector("code.language-python");
|
||||
expect(codeElement).toBeInTheDocument();
|
||||
expect(codeElement?.textContent).toContain(code);
|
||||
});
|
||||
});
|
||||
|
||||
it("should fetch and display images from annotations", async () => {
|
||||
const mockBlob = new Blob(["image data"], { type: "image/png" });
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
blob: vi.fn().mockResolvedValue(mockBlob),
|
||||
};
|
||||
|
||||
(global.fetch as any).mockResolvedValue(mockResponse);
|
||||
|
||||
const annotations = [
|
||||
{
|
||||
type: "container_file_citation" as const,
|
||||
container_id: "container-1",
|
||||
file_id: "file-1",
|
||||
filename: "chart.png",
|
||||
start_index: 0,
|
||||
end_index: 10,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<CodeInterpreterOutput
|
||||
code="import matplotlib.pyplot as plt"
|
||||
annotations={annotations}
|
||||
accessToken="test-token"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://example.com/v1/containers/container-1/files/file-1/content",
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
Authorization: "Bearer test-token",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("chart.png")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should show loading state while fetching images", async () => {
|
||||
const mockBlob = new Blob(["image data"], { type: "image/png" });
|
||||
let resolveBlob: (value: Blob) => void;
|
||||
const blobPromise = new Promise<Blob>((resolve) => {
|
||||
resolveBlob = resolve;
|
||||
});
|
||||
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
blob: vi.fn().mockReturnValue(blobPromise),
|
||||
};
|
||||
|
||||
(global.fetch as any).mockResolvedValue(mockResponse);
|
||||
|
||||
const annotations = [
|
||||
{
|
||||
type: "container_file_citation" as const,
|
||||
container_id: "container-1",
|
||||
file_id: "file-1",
|
||||
filename: "chart.png",
|
||||
start_index: 0,
|
||||
end_index: 10,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<CodeInterpreterOutput
|
||||
code="import matplotlib.pyplot as plt"
|
||||
annotations={annotations}
|
||||
accessToken="test-token"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Loading image...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
resolveBlob!(mockBlob);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Loading image...")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle download for image files", async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockBlob = new Blob(["image data"], { type: "image/png" });
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
blob: vi.fn().mockResolvedValue(mockBlob),
|
||||
};
|
||||
|
||||
(global.fetch as any).mockResolvedValue(mockResponse);
|
||||
|
||||
const annotations = [
|
||||
{
|
||||
type: "container_file_citation" as const,
|
||||
container_id: "container-1",
|
||||
file_id: "file-1",
|
||||
filename: "chart.png",
|
||||
start_index: 0,
|
||||
end_index: 10,
|
||||
},
|
||||
];
|
||||
|
||||
const createElementSpy = vi.spyOn(document, "createElement");
|
||||
const appendChildSpy = vi.spyOn(document.body, "appendChild");
|
||||
const removeChildSpy = vi.spyOn(document.body, "removeChild");
|
||||
|
||||
render(
|
||||
<CodeInterpreterOutput
|
||||
code="import matplotlib.pyplot as plt"
|
||||
annotations={annotations}
|
||||
accessToken="test-token"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("chart.png")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const downloadButton = screen.getByText("Download");
|
||||
await user.click(downloadButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://example.com/v1/containers/container-1/files/file-1/content",
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
Authorization: "Bearer test-token",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
createElementSpy.mockRestore();
|
||||
appendChildSpy.mockRestore();
|
||||
removeChildSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should handle download for non-image files", async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockBlob = new Blob(["file data"], { type: "text/plain" });
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
blob: vi.fn().mockResolvedValue(mockBlob),
|
||||
};
|
||||
|
||||
(global.fetch as any).mockResolvedValue(mockResponse);
|
||||
|
||||
const annotations = [
|
||||
{
|
||||
type: "container_file_citation" as const,
|
||||
container_id: "container-1",
|
||||
file_id: "file-1",
|
||||
filename: "data.csv",
|
||||
start_index: 0,
|
||||
end_index: 10,
|
||||
},
|
||||
];
|
||||
|
||||
render(<CodeInterpreterOutput code="import pandas as pd" annotations={annotations} accessToken="test-token" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("data.csv")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const downloadButton = screen.getByText("data.csv").closest("button");
|
||||
expect(downloadButton).toBeInTheDocument();
|
||||
if (downloadButton) {
|
||||
await user.click(downloadButton);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://example.com/v1/containers/container-1/files/file-1/content",
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
Authorization: "Bearer test-token",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should return null when no code and no annotations", () => {
|
||||
const { container } = render(<CodeInterpreterOutput accessToken="test-token" />);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle multiple image formats", async () => {
|
||||
const mockBlob = new Blob(["image data"], { type: "image/png" });
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
blob: vi.fn().mockResolvedValue(mockBlob),
|
||||
};
|
||||
|
||||
(global.fetch as any).mockResolvedValue(mockResponse);
|
||||
|
||||
const annotations = [
|
||||
{
|
||||
type: "container_file_citation" as const,
|
||||
container_id: "container-1",
|
||||
file_id: "file-1",
|
||||
filename: "image.png",
|
||||
start_index: 0,
|
||||
end_index: 10,
|
||||
},
|
||||
{
|
||||
type: "container_file_citation" as const,
|
||||
container_id: "container-1",
|
||||
file_id: "file-2",
|
||||
filename: "image.jpg",
|
||||
start_index: 0,
|
||||
end_index: 10,
|
||||
},
|
||||
{
|
||||
type: "container_file_citation" as const,
|
||||
container_id: "container-1",
|
||||
file_id: "file-3",
|
||||
filename: "image.jpeg",
|
||||
start_index: 0,
|
||||
end_index: 10,
|
||||
},
|
||||
{
|
||||
type: "container_file_citation" as const,
|
||||
container_id: "container-1",
|
||||
file_id: "file-4",
|
||||
filename: "image.gif",
|
||||
start_index: 0,
|
||||
end_index: 10,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<CodeInterpreterOutput
|
||||
code="import matplotlib.pyplot as plt"
|
||||
annotations={annotations}
|
||||
accessToken="test-token"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle fetch errors gracefully", async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
(global.fetch as any).mockRejectedValue(new Error("Network error"));
|
||||
|
||||
const annotations = [
|
||||
{
|
||||
type: "container_file_citation" as const,
|
||||
container_id: "container-1",
|
||||
file_id: "file-1",
|
||||
filename: "chart.png",
|
||||
start_index: 0,
|
||||
end_index: 10,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<CodeInterpreterOutput
|
||||
code="import matplotlib.pyplot as plt"
|
||||
annotations={annotations}
|
||||
accessToken="test-token"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { UnifiedSelector } from "./UnifiedSelector";
|
||||
import { EndpointId, ENDPOINT_CONFIGS } from "../endpoint_config";
|
||||
|
||||
describe("UnifiedSelector", () => {
|
||||
it("should render", () => {
|
||||
const onChange = vi.fn();
|
||||
const options = [
|
||||
{ value: "option1", label: "Option 1" },
|
||||
{ value: "option2", label: "Option 2" },
|
||||
];
|
||||
const config = ENDPOINT_CONFIGS[EndpointId.CHAT_COMPLETIONS];
|
||||
|
||||
render(<UnifiedSelector value="" options={options} loading={false} config={config} onChange={onChange} />);
|
||||
|
||||
const select = screen.getByRole("combobox");
|
||||
expect(select).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display placeholder when not loading", () => {
|
||||
const onChange = vi.fn();
|
||||
const options = [{ value: "option1", label: "Option 1" }];
|
||||
const config = ENDPOINT_CONFIGS[EndpointId.CHAT_COMPLETIONS];
|
||||
|
||||
const { container } = render(
|
||||
<UnifiedSelector value="" options={options} loading={false} config={config} onChange={onChange} />,
|
||||
);
|
||||
|
||||
const placeholder = container.querySelector(".ant-select-selection-placeholder");
|
||||
expect(placeholder).toHaveTextContent(config.selectorPlaceholder);
|
||||
});
|
||||
|
||||
it("should display loading placeholder when loading", () => {
|
||||
const onChange = vi.fn();
|
||||
const options = [{ value: "option1", label: "Option 1" }];
|
||||
const config = ENDPOINT_CONFIGS[EndpointId.CHAT_COMPLETIONS];
|
||||
|
||||
const { container } = render(
|
||||
<UnifiedSelector value="" options={options} loading={true} config={config} onChange={onChange} />,
|
||||
);
|
||||
|
||||
const placeholder = container.querySelector(".ant-select-selection-placeholder");
|
||||
expect(placeholder).toHaveTextContent(`Loading ${config.selectorLabel.toLowerCase()}s...`);
|
||||
});
|
||||
|
||||
it("should call onChange when option is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const options = [
|
||||
{ value: "option1", label: "Option 1" },
|
||||
{ value: "option2", label: "Option 2" },
|
||||
];
|
||||
const config = ENDPOINT_CONFIGS[EndpointId.CHAT_COMPLETIONS];
|
||||
|
||||
render(<UnifiedSelector value="" options={options} loading={false} config={config} onChange={onChange} />);
|
||||
|
||||
const select = screen.getByRole("combobox");
|
||||
await user.click(select);
|
||||
|
||||
await waitFor(() => {
|
||||
const option = screen.getByText("Option 1");
|
||||
expect(option).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const option = screen.getByText("Option 1");
|
||||
await user.click(option);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
});
|
||||
const callArgs = onChange.mock.calls[0];
|
||||
expect(callArgs[0]).toBe("option1");
|
||||
});
|
||||
|
||||
it("should display selected value", () => {
|
||||
const onChange = vi.fn();
|
||||
const options = [
|
||||
{ value: "option1", label: "Option 1" },
|
||||
{ value: "option2", label: "Option 2" },
|
||||
];
|
||||
const config = ENDPOINT_CONFIGS[EndpointId.CHAT_COMPLETIONS];
|
||||
|
||||
const { container } = render(
|
||||
<UnifiedSelector value="option1" options={options} loading={false} config={config} onChange={onChange} />,
|
||||
);
|
||||
|
||||
const selectedValue = container.querySelector(".ant-select-selection-item");
|
||||
expect(selectedValue).toHaveTextContent("Option 1");
|
||||
});
|
||||
|
||||
it("should filter options by search input", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const options = [
|
||||
{ value: "option1", label: "Option One" },
|
||||
{ value: "option2", label: "Option Two" },
|
||||
{ value: "option3", label: "Different" },
|
||||
];
|
||||
const config = ENDPOINT_CONFIGS[EndpointId.CHAT_COMPLETIONS];
|
||||
|
||||
render(<UnifiedSelector value="" options={options} loading={false} config={config} onChange={onChange} />);
|
||||
|
||||
const select = screen.getByRole("combobox");
|
||||
await user.click(select);
|
||||
await user.type(select, "One");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Option One")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Option Two")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Different")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should show loading spinner in notFoundContent when loading", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const options: { value: string; label: string }[] = [];
|
||||
const config = ENDPOINT_CONFIGS[EndpointId.CHAT_COMPLETIONS];
|
||||
|
||||
render(<UnifiedSelector value="" options={options} loading={true} config={config} onChange={onChange} />);
|
||||
|
||||
const select = screen.getByRole("combobox");
|
||||
await user.click(select);
|
||||
|
||||
await waitFor(() => {
|
||||
const spin = document.querySelector(".ant-spin");
|
||||
expect(spin).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should show no options message when not loading and no options", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const options: { value: string; label: string }[] = [];
|
||||
const config = ENDPOINT_CONFIGS[EndpointId.CHAT_COMPLETIONS];
|
||||
|
||||
render(<UnifiedSelector value="" options={options} loading={false} config={config} onChange={onChange} />);
|
||||
|
||||
const select = screen.getByRole("combobox");
|
||||
await user.click(select);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(`No ${config.selectorLabel.toLowerCase()}s available`)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should work with agent endpoint config", () => {
|
||||
const onChange = vi.fn();
|
||||
const options = [{ value: "agent1", label: "Agent One" }];
|
||||
const config = ENDPOINT_CONFIGS[EndpointId.A2A_AGENTS];
|
||||
|
||||
const { container } = render(
|
||||
<UnifiedSelector value="" options={options} loading={false} config={config} onChange={onChange} />,
|
||||
);
|
||||
|
||||
const placeholder = container.querySelector(".ant-select-selection-placeholder");
|
||||
expect(placeholder).toHaveTextContent(config.selectorPlaceholder);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
EndpointId,
|
||||
ENDPOINT_CONFIGS,
|
||||
getAvailableEndpoints,
|
||||
getEndpointConfig,
|
||||
isAgentEndpoint,
|
||||
isModelEndpoint,
|
||||
modelOptionsToSelectorOptions,
|
||||
agentOptionsToSelectorOptions,
|
||||
getSelectionFieldName,
|
||||
getComparisonSelection,
|
||||
hasValidSelection,
|
||||
} from "./endpoint_config";
|
||||
import { Agent } from "../llm_calls/fetch_agents";
|
||||
|
||||
describe("endpoint_config", () => {
|
||||
it("should export EndpointId constants", () => {
|
||||
expect(EndpointId.CHAT_COMPLETIONS).toBe("/v1/chat/completions");
|
||||
expect(EndpointId.A2A_AGENTS).toBe("/a2a");
|
||||
});
|
||||
|
||||
it("should have endpoint configs for all endpoint IDs", () => {
|
||||
expect(ENDPOINT_CONFIGS[EndpointId.CHAT_COMPLETIONS]).toBeDefined();
|
||||
expect(ENDPOINT_CONFIGS[EndpointId.A2A_AGENTS]).toBeDefined();
|
||||
expect(ENDPOINT_CONFIGS[EndpointId.CHAT_COMPLETIONS].selectorType).toBe("model");
|
||||
expect(ENDPOINT_CONFIGS[EndpointId.A2A_AGENTS].selectorType).toBe("agent");
|
||||
});
|
||||
|
||||
it("should get available endpoints", () => {
|
||||
const endpoints = getAvailableEndpoints();
|
||||
expect(endpoints).toHaveLength(2);
|
||||
expect(endpoints).toContainEqual({
|
||||
value: EndpointId.CHAT_COMPLETIONS,
|
||||
label: "/v1/chat/completions",
|
||||
});
|
||||
expect(endpoints).toContainEqual({
|
||||
value: EndpointId.A2A_AGENTS,
|
||||
label: "/a2a (Agents)",
|
||||
});
|
||||
});
|
||||
|
||||
it("should get endpoint config by ID", () => {
|
||||
const config = getEndpointConfig(EndpointId.CHAT_COMPLETIONS);
|
||||
expect(config.id).toBe(EndpointId.CHAT_COMPLETIONS);
|
||||
expect(config.selectorType).toBe("model");
|
||||
expect(config.selectorLabel).toBe("Model");
|
||||
});
|
||||
|
||||
it("should check if endpoint is agent endpoint", () => {
|
||||
expect(isAgentEndpoint(EndpointId.A2A_AGENTS)).toBe(true);
|
||||
expect(isAgentEndpoint(EndpointId.CHAT_COMPLETIONS)).toBe(false);
|
||||
});
|
||||
|
||||
it("should check if endpoint is model endpoint", () => {
|
||||
expect(isModelEndpoint(EndpointId.CHAT_COMPLETIONS)).toBe(true);
|
||||
expect(isModelEndpoint(EndpointId.A2A_AGENTS)).toBe(false);
|
||||
});
|
||||
|
||||
it("should convert model options to selector options", () => {
|
||||
const models = ["gpt-4", "gpt-3.5-turbo", "claude-3"];
|
||||
const options = modelOptionsToSelectorOptions(models);
|
||||
expect(options).toHaveLength(3);
|
||||
expect(options[0]).toEqual({ value: "gpt-4", label: "gpt-4" });
|
||||
expect(options[1]).toEqual({ value: "gpt-3.5-turbo", label: "gpt-3.5-turbo" });
|
||||
expect(options[2]).toEqual({ value: "claude-3", label: "claude-3" });
|
||||
});
|
||||
|
||||
it("should convert agent options to selector options", () => {
|
||||
const agents: Agent[] = [
|
||||
{ agent_id: "agent-1", agent_name: "Agent One" },
|
||||
{ agent_id: "agent-2", agent_name: "Agent Two" },
|
||||
{ agent_id: "agent-3", agent_name: undefined as any },
|
||||
];
|
||||
const options = agentOptionsToSelectorOptions(agents);
|
||||
expect(options).toHaveLength(3);
|
||||
expect(options[0]).toEqual({ value: "Agent One", label: "Agent One" });
|
||||
expect(options[1]).toEqual({ value: "Agent Two", label: "Agent Two" });
|
||||
expect(options[2]).toEqual({ value: undefined, label: "agent-3" });
|
||||
});
|
||||
|
||||
it("should get selection field name based on endpoint", () => {
|
||||
expect(getSelectionFieldName(EndpointId.CHAT_COMPLETIONS)).toBe("model");
|
||||
expect(getSelectionFieldName(EndpointId.A2A_AGENTS)).toBe("agent");
|
||||
});
|
||||
|
||||
it("should get comparison selection based on endpoint", () => {
|
||||
const comparison = { model: "gpt-4", agent: "agent-1" };
|
||||
expect(getComparisonSelection(comparison, EndpointId.CHAT_COMPLETIONS)).toBe("gpt-4");
|
||||
expect(getComparisonSelection(comparison, EndpointId.A2A_AGENTS)).toBe("agent-1");
|
||||
});
|
||||
|
||||
it("should check if comparison has valid selection", () => {
|
||||
const comparisonWithModel = { model: "gpt-4", agent: "" };
|
||||
const comparisonWithAgent = { model: "", agent: "agent-1" };
|
||||
const comparisonEmpty = { model: "", agent: "" };
|
||||
const comparisonWhitespace = { model: " ", agent: "" };
|
||||
|
||||
expect(hasValidSelection(comparisonWithModel, EndpointId.CHAT_COMPLETIONS)).toBe(true);
|
||||
expect(hasValidSelection(comparisonWithAgent, EndpointId.A2A_AGENTS)).toBe(true);
|
||||
expect(hasValidSelection(comparisonEmpty, EndpointId.CHAT_COMPLETIONS)).toBe(false);
|
||||
expect(hasValidSelection(comparisonWhitespace, EndpointId.CHAT_COMPLETIONS)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -119,8 +119,25 @@ describe("chat_completion", () => {
|
|||
it("should configure MCP tools per server with restrictions", async () => {
|
||||
const selectedMCPServers = ["server-1", "server-2"];
|
||||
const mcpServers = [
|
||||
{ server_id: "server-1", alias: "alpha", server_name: "Alpha" },
|
||||
{ server_id: "server-2", server_name: "Beta" },
|
||||
{
|
||||
server_id: "server-1",
|
||||
alias: "alpha",
|
||||
server_name: "Alpha",
|
||||
url: "http://example.com",
|
||||
created_at: "2024-01-01",
|
||||
created_by: "test",
|
||||
updated_at: "2024-01-01",
|
||||
updated_by: "test",
|
||||
},
|
||||
{
|
||||
server_id: "server-2",
|
||||
server_name: "Beta",
|
||||
url: "http://example.com",
|
||||
created_at: "2024-01-01",
|
||||
created_by: "test",
|
||||
updated_at: "2024-01-01",
|
||||
updated_by: "test",
|
||||
},
|
||||
];
|
||||
const mcpServerToolRestrictions = {
|
||||
"server-1": ["toolA", "toolB"],
|
||||
|
|
@ -132,41 +149,43 @@ describe("chat_completion", () => {
|
|||
mockUpdateUI,
|
||||
"gpt-4",
|
||||
"test-token",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined, // tags
|
||||
undefined, // signal
|
||||
undefined, // onReasoningContent
|
||||
undefined, // onTimingData
|
||||
undefined, // onUsageData
|
||||
undefined, // traceId
|
||||
undefined, // vector_store_ids
|
||||
undefined, // guardrails
|
||||
selectedMCPServers,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined, // onImageGenerated
|
||||
undefined, // onSearchResults
|
||||
undefined, // temperature
|
||||
undefined, // max_tokens
|
||||
undefined, // onTotalLatency
|
||||
undefined, // customBaseUrl
|
||||
mcpServers,
|
||||
mcpServerToolRestrictions,
|
||||
);
|
||||
|
||||
const callArgs = mockCreate.mock.calls[0][0];
|
||||
expect(callArgs.tool_choice).toBe("auto");
|
||||
expect(callArgs.tools).toEqual([
|
||||
{
|
||||
type: "mcp",
|
||||
server_label: "litellm",
|
||||
server_url: "litellm_proxy/mcp/alpha",
|
||||
require_approval: "never",
|
||||
allowed_tools: ["toolA", "toolB"],
|
||||
},
|
||||
{
|
||||
type: "mcp",
|
||||
server_label: "litellm",
|
||||
server_url: "litellm_proxy/mcp/Beta",
|
||||
require_approval: "never",
|
||||
allowed_tools: ["toolC"],
|
||||
},
|
||||
]);
|
||||
expect(callArgs.tools).toHaveLength(2);
|
||||
|
||||
// Check first tool
|
||||
const firstTool = callArgs.tools[0];
|
||||
expect(firstTool.type).toBe("mcp");
|
||||
expect(firstTool.server_label).toBe("litellm");
|
||||
expect(firstTool.server_url).toBe("litellm_proxy/mcp/alpha");
|
||||
expect(firstTool.require_approval).toBe("never");
|
||||
expect(firstTool.allowed_tools).toEqual(["toolA", "toolB"]);
|
||||
|
||||
// Check second tool
|
||||
const secondTool = callArgs.tools[1];
|
||||
expect(secondTool.type).toBe("mcp");
|
||||
expect(secondTool.server_label).toBe("litellm");
|
||||
expect(secondTool.server_url).toBe("litellm_proxy/mcp/Beta");
|
||||
expect(secondTool.require_approval).toBe("never");
|
||||
expect(secondTool.allowed_tools).toEqual(["toolC"]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -72,8 +72,25 @@ describe("responses_api", () => {
|
|||
it("should configure MCP tools per server with restrictions", async () => {
|
||||
const selectedMCPServers = ["server-1", "server-2"];
|
||||
const mcpServers = [
|
||||
{ server_id: "server-1", alias: "alpha", server_name: "Alpha" },
|
||||
{ server_id: "server-2", server_name: "Beta" },
|
||||
{
|
||||
server_id: "server-1",
|
||||
alias: "alpha",
|
||||
server_name: "Alpha",
|
||||
url: "http://example.com",
|
||||
created_at: "2024-01-01",
|
||||
created_by: "test",
|
||||
updated_at: "2024-01-01",
|
||||
updated_by: "test",
|
||||
},
|
||||
{
|
||||
server_id: "server-2",
|
||||
server_name: "Beta",
|
||||
url: "http://example.com",
|
||||
created_at: "2024-01-01",
|
||||
created_by: "test",
|
||||
updated_at: "2024-01-01",
|
||||
updated_by: "test",
|
||||
},
|
||||
];
|
||||
const mcpServerToolRestrictions: Record<string, string[]> = {
|
||||
"server-1": ["toolA"],
|
||||
|
|
@ -99,6 +116,7 @@ describe("responses_api", () => {
|
|||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
mcpServers,
|
||||
mcpServerToolRestrictions,
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue