Merge pull request #23276 from BerriAI/litellm_oss_staging_03_10_2026

Litellm oss staging 03 10 2026
This commit is contained in:
Sameer Kankute
2026-03-11 18:54:38 +05:30
committed by GitHub
88 changed files with 7769 additions and 4010 deletions
+7
View File
@@ -110,6 +110,13 @@ LiteLLM is a unified interface for 100+ LLM providers with two main components:
### Proxy database access
- **Do not write raw SQL** for proxy DB operations. Use Prisma model methods instead of `execute_raw` / `query_raw`.
- Use the generated client: `prisma_client.db.<model>` (e.g. `litellm_tooltable`, `litellm_usertable`) with `.upsert()`, `.find_many()`, `.find_unique()`, `.update()`, `.update_many()` as appropriate. This avoids schema/client drift, keeps code testable with simple mocks, and matches patterns used in spend logs and other proxy code.
- **No N+1 queries.** Never query the DB inside a loop. Batch-fetch with `{"in": ids}` and distribute in-memory.
- **Batch writes.** Use `create_many`/`update_many`/`delete_many` instead of individual calls (these return counts only; `update_many`/`delete_many` no-op silently on missing rows). When multiple separate writes target the same table (e.g. in `batch_()`), order by primary key to avoid deadlocks.
- **Push work to the DB.** Filter, sort, group, and aggregate in SQL, not Python. Verify Prisma generates the expected SQL — e.g. prefer `group_by` over `find_many(distinct=...)` which does client-side processing.
- **Bound large result sets.** Prisma materializes full results in memory. For results over ~10 MB, paginate with `take`/`skip` or `cursor`/`take`, always with an explicit `order`. Prefer cursor-based pagination (`skip` is O(n)). Don't paginate naturally small result sets.
- **Limit fetched columns on wide tables.** Use `select` to fetch only needed fields — returns a partial object, so downstream code must not access unselected fields.
- **Check index coverage.** For new or modified queries, check `schema.prisma` for a supporting index. Prefer extending an existing index (e.g. `@@index([a])``@@index([a, b])`) over adding a new one, unless it's a `@@unique`. Only add indexes for large/frequent queries.
- **Keep schema files in sync.** Apply schema changes to all `schema.prisma` copies (`schema.prisma`, `litellm/proxy/`, `litellm-proxy-extras/`, `litellm-js/spend-logs/` for SpendLogs) with a migration under `litellm-proxy-extras/litellm_proxy_extras/migrations/`.
### Enterprise Features
- Enterprise-specific code in `enterprise/` directory
+1
View File
@@ -11,6 +11,7 @@ This endpoint supports various guardrail types including:
- **Presidio** - PII detection and masking
- **Bedrock** - AWS Bedrock guardrails for content moderation
- **Lakera** - AI safety guardrails
- **PANW Prisma AIRS** - Threat detection, DLP, and policy enforcement
- **Custom guardrails** - User-defined guardrails
## Configuration
+22
View File
@@ -51,6 +51,28 @@ Here's what an example response looks like
}
```
## Native Finish Reason
LiteLLM maps all provider-specific `finish_reason` values to OpenAI-compatible values (`stop`, `length`, `tool_calls`, `function_call`, `content_filter`). When the original provider value differs from the mapped value, it is preserved in `provider_specific_fields["native_finish_reason"]`.
This is useful for agent loops that need to distinguish between different stop conditions (e.g., Gemini's `MALFORMED_FUNCTION_CALL` vs a normal `stop`).
```python
response = completion(model="gemini/gemini-2.0-flash", messages=messages)
choice = response.choices[0]
print(choice.finish_reason) # "stop" (OpenAI-compatible)
# Access the original provider value when it differs:
if hasattr(choice, "provider_specific_fields") and choice.provider_specific_fields:
native = choice.provider_specific_fields.get("native_finish_reason")
if native == "MALFORMED_FUNCTION_CALL":
# Handle malformed function call differently from a normal stop
pass
```
When the provider already returns an OpenAI-compatible value (e.g., `stop`), `native_finish_reason` is not set.
## Additional Attributes
You can also access information like latency.
@@ -80,6 +80,36 @@ That's it! The provider is now available.
}
```
## Responses API Support
If your provider also supports the OpenAI Responses API (`/v1/responses`), add `supported_endpoints`:
```json
{
"your_provider": {
"base_url": "https://api.yourprovider.com/v1",
"api_key_env": "YOUR_PROVIDER_API_KEY",
"supported_endpoints": ["/v1/chat/completions", "/v1/responses"]
}
}
```
This enables `litellm.responses()` with zero additional code:
```python
import litellm
response = litellm.responses(
model="your_provider/model-name",
input="Hello, what can you do?",
)
print(response.output)
```
If `supported_endpoints` is omitted, it defaults to `[]`. Chat completions is always enabled for JSON providers regardless of this field.
The provider inherits all request/response handling from OpenAI's Responses API — streaming, tools, and all standard parameters work out of the box.
## Usage
```python
@@ -89,11 +119,17 @@ import os
# Set your API key
os.environ["YOUR_PROVIDER_API_KEY"] = "your-key-here"
# Use the provider
# Chat completions
response = litellm.completion(
model="your_provider/model-name",
messages=[{"role": "user", "content": "Hello"}],
)
# Responses API (if supported_endpoints includes "/v1/responses")
response = litellm.responses(
model="your_provider/model-name",
input="Hello",
)
```
## When to Use Python Instead
@@ -105,7 +141,9 @@ Use a Python config class if you need:
- Provider-specific streaming logic
- Advanced tool calling modifications
For these cases, create a config class in `litellm/llms/your_provider/chat/transformation.py` that inherits from `OpenAIGPTConfig` or `OpenAILikeChatConfig`.
For chat completions, create a config class in `litellm/llms/your_provider/chat/transformation.py` that inherits from `OpenAIGPTConfig` or `OpenAILikeChatConfig`.
For responses API with small overrides, inherit from `OpenAIResponsesAPIConfig` and override only what's needed. See `litellm/llms/perplexity/responses/transformation.py` for a minimal example (~40 lines vs 400+).
## Testing
+1
View File
@@ -86,4 +86,5 @@ MCP guardrails work with all LiteLLM-supported guardrail providers:
- **Lakera**: Content moderation
- **Aporia**: Custom guardrails
- **Noma**: Noma Security
- **PANW Prisma AIRS**: Prisma AIRS guardrails
- **Custom**: Your own guardrail implementations
@@ -13,6 +13,7 @@ Here's the full specification with all available fields:
```json
{
"sample_spec": {
"aliases": ["optional list of alternate names for this model, e.g. dated versions like sample_spec-20250101"],
"code_interpreter_cost_per_session": 0.0,
"computer_use_input_cost_per_1k_tokens": 0.0,
"computer_use_output_cost_per_1k_tokens": 0.0,
@@ -121,4 +122,28 @@ Here's the full specification with all available fields:
}
```
That's it! Your PR will be reviewed and merged.
### Using Aliases
Many providers release the same model under multiple names — for example, a `latest` tag and a dated version like `claude-sonnet-4-5-20250929`. Instead of duplicating the entire entry, you can use the `aliases` field:
```json
{
"claude-sonnet-4-5": {
"aliases": ["claude-sonnet-4-5-20250929"],
"input_cost_per_token": 3e-06,
"output_cost_per_token": 1.5e-05,
"litellm_provider": "anthropic",
"max_input_tokens": 200000,
"max_output_tokens": 64000,
"mode": "chat",
"supports_function_calling": true,
"supports_tool_choice": true
}
}
```
At load time, each alias is expanded into a top-level entry sharing the same data as the canonical entry. The example above makes both `claude-sonnet-4-5` and `claude-sonnet-4-5-20250929` resolve with the same pricing and capabilities.
:::info
This is different from [`model_alias_map`](../completion/model_alias.md), which is a runtime SDK/proxy feature for mapping user-facing model names to LiteLLM model identifiers. The `aliases` field here is for the model cost JSON only — it avoids duplicate entries for models that share identical pricing and capabilities.
:::
@@ -1,24 +1,15 @@
import Image from '@theme/IdealImage';
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
# PANW Prisma AIRS
LiteLLM supports PANW Prisma AIRS (AI Runtime Security) guardrails via the [Prisma AIRS Scan API](https://pan.dev/prisma-airs/api/airuntimesecurity/airuntimesecurityapi//). This integration provides **Security-as-Code** for AI applications using Palo Alto Networks' AI security platform.
LiteLLM supports PANW Prisma AIRS (AI Runtime Security) guardrails via the [Prisma AIRS Scan API](https://pan.dev/prisma-airs/api/airuntimesecurity/airuntimesecurityapi/). This integration provides Security-as-Code for AI applications using Palo Alto Networks' AI security platform.
## Features
- **Prompt injection and malicious URL detection** — real-time scanning before or after LLM calls
- **Data loss prevention (DLP)** — detect and block sensitive data in prompts and responses
- **Sensitive content masking** — automatically mask PII, credit cards, SSNs instead of blocking
- **MCP tool call scanning** — scan tool name and arguments on direct MCP tool invocations
- **Configurable fail-open / fail-closed** — choose between maximum security or high availability
-**Real-time prompt injection detection**
-**Malicious URL detection**
-**Data loss prevention (DLP)**
-**Sensitive content masking** - Automatically mask PII, credit cards, SSNs instead of blocking
-**Comprehensive threat detection** for AI models and datasets
-**Model-agnostic protection** across public and private models
-**Synchronous scanning** with immediate response
-**Configurable security profiles**
-**Streaming support** - Real-time masking for streaming responses
-**Multi-turn conversation tracking** - Automatic session grouping in Prisma AIRS SCM logs
-**Configurable fail-open/fail-closed** - Choose between maximum security (block on API errors) or high availability (allow on transient errors)
## Quick Start
@@ -32,7 +23,14 @@ For detailed setup instructions, see the [Prisma AIRS API Overview](https://docs
### 2. Define Guardrails on your LiteLLM config.yaml
Define your guardrails under the `guardrails` section:
Set `api_base` to the regional endpoint for your Prisma AIRS deployment profile:
| Region | Endpoint |
|--------|----------|
| US | `https://service.api.aisecurity.paloaltonetworks.com` |
| EU (Germany) | `https://service-de.api.aisecurity.paloaltonetworks.com` |
| India | `https://service-in.api.aisecurity.paloaltonetworks.com` |
| Singapore | `https://service-sg.api.aisecurity.paloaltonetworks.com` |
```yaml
model_list:
@@ -45,21 +43,15 @@ guardrails:
- guardrail_name: "panw-prisma-airs-guardrail"
litellm_params:
guardrail: panw_prisma_airs
mode: "pre_call" # Run before LLM call
api_key: os.environ/PANW_PRISMA_AIRS_API_KEY # Your Prisma AIRS API key
profile_name: os.environ/PANW_PRISMA_AIRS_PROFILE_NAME # Security profile from Strata Cloud Manager
api_base: "https://service.api.aisecurity.paloaltonetworks.com"
mode: "pre_call"
api_key: os.environ/PANW_PRISMA_AIRS_API_KEY
profile_name: os.environ/PANW_PRISMA_AIRS_PROFILE_NAME
api_base: "https://service.api.aisecurity.paloaltonetworks.com" # US — change to your region
```
#### Supported values for `mode`
- `pre_call` Run **before** LLM call, on **input**
- `post_call` Run **after** LLM call, on **input & output**
- `during_call` Run **during** LLM call, on **input**. Same as `pre_call` but runs in parallel with LLM call
### 3. Start LiteLLM Gateway
```bash title="Set environment variables"
```bash
export PANW_PRISMA_AIRS_API_KEY="your-panw-api-key"
export PANW_PRISMA_AIRS_PROFILE_NAME="your-security-profile"
export OPENAI_API_KEY="sk-proj-..."
@@ -69,15 +61,8 @@ export OPENAI_API_KEY="sk-proj-..."
litellm --config config.yaml --detailed_debug
```
### 4. Test Request
**[Langchain, OpenAI SDK Usage Examples](../proxy/user_keys#request-format)**
<Tabs>
<TabItem label="Blocked request" value="blocked">
Expect this to fail due to prompt injection attempt:
```shell
curl -i http://localhost:4000/v1/chat/completions \
@@ -92,254 +77,57 @@ curl -i http://localhost:4000/v1/chat/completions \
}'
```
Expected response on failure:
Expected response when the guardrail blocks:
```json
{
"error": {
"message": {
"error": "Violated PANW Prisma AIRS guardrail policy",
"panw_response": {
"action": "block",
"category": "malicious",
"profile_id": "03b32734-d06d-4bb7-a8df-ac5147630ce8",
"profile_name": "dev-block-all-profile",
"prompt_detected": {
"dlp": false,
"injection": true,
"toxic_content": false,
"url_cats": false
},
"report_id": "Rbd251eac-6e67-433b-b3ef-8eb42d2c7d2c",
"response_detected": {
"dlp": false,
"toxic_content": false,
"url_cats": false
},
"scan_id": "bd251eac-6e67-433b-b3ef-8eb42d2c7d2c",
"tr_id": "string"
}
},
"type": "None",
"param": "None",
"code": "400"
"message": "Prompt blocked by PANW Prisma AI Security policy (Category: malicious)",
"type": "guardrail_violation",
"code": "panw_prisma_airs_blocked",
"guardrail": "panw-prisma-airs-guardrail",
"category": "malicious"
}
}
```
</TabItem>
<TabItem label="Successful Call" value="allowed">
LiteLLM wraps this detail in an endpoint-specific HTTP error envelope. Optional fields that may also appear: `scan_id`, `report_id`, `profile_name`, `profile_id`, `tr_id`, `prompt_detected`.
```shell
curl -i http://localhost:4000/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer sk-your-api-key" \
-d '{
"model": "gpt-4o",
"messages": [
{"role": "user", "content": "What is the weather like today?"}
],
"guardrails": ["panw-prisma-airs-guardrail"]
}'
```
On success, the guardrail name appears in the `x-litellm-applied-guardrails` response header.
Expected successful response:
## Configuration
```json
{
"choices": [
{
"finish_reason": "stop",
"index": 0,
"message": {
"content": "I don't have access to real-time weather data, but I can help you find weather information through various weather services or apps...",
"role": "assistant",
"tool_calls": null,
"function_call": null,
"annotations": []
}
}
],
"created": 1736028456,
"id": "chatcmpl-AqQj8example",
"model": "gpt-4o",
"object": "chat.completion",
"usage": {
"completion_tokens": 25,
"prompt_tokens": 12,
"total_tokens": 37
},
"x-litellm-panw-scan": {
"action": "allow",
"category": "benign",
"profile_id": "03b32734-d06d-4bb7-a8df-ac5147630ce8",
"profile_name": "dev-block-all-profile",
"prompt_detected": {
"dlp": false,
"injection": false,
"toxic_content": false,
"url_cats": false
},
"report_id": "Rbd251eac-6e67-433b-b3ef-8eb42d2c7d2c",
"response_detected": {
"dlp": false,
"toxic_content": false,
"url_cats": false
},
"scan_id": "bd251eac-6e67-433b-b3ef-8eb42d2c7d2c",
"tr_id": "string"
}
}
```
### Supported Modes
</TabItem>
</Tabs>
| Mode | Timing | What is scanned |
|------|--------|-----------------|
| `pre_call` | Before LLM call | Request input |
| `during_call` | Parallel with LLM call | Request input |
| `post_call` | After LLM call | Response output |
| `pre_mcp_call` | Before MCP tool execution | MCP tool input |
| `during_mcp_call` | Parallel with MCP tool execution | MCP tool input |
## Configuration Parameters
### Configuration Parameters
| Parameter | Required | Description | Default |
|-----------|----------|-------------|---------|
| `api_key` | Yes | Your PANW Prisma AIRS API key from Strata Cloud Manager | - |
| `profile_name` | No | Security profile name configured in Strata Cloud Manager. Optional if API key has linked profile | - |
| `app_name` | No | Application identifier for tracking in Prisma AIRS analytics (will be prefixed with "LiteLLM-") | `LiteLLM` |
| `api_base` | No | Regional API endpoint (see [Regional Endpoints](#regional-endpoints) below) | `https://service.api.aisecurity.paloaltonetworks.com` (US) |
| `mode` | No | When to run the guardrail | `pre_call` |
| `fallback_on_error` | No | Action when PANW API is unavailable: `"block"` (fail-closed, default) or `"allow"` (fail-open). Config errors always block. | `block` |
| `timeout` | No | PANW API call timeout in seconds (1-60) | `10.0` |
| `violation_message_template` | No | Custom template for error message when request is blocked. Supports `{guardrail_name}`, `{category}`, `{action_type}`, `{default_message}` placeholders. | - |
| `app_name` | No | Application identifier for tracking in Prisma AIRS analytics (prefixed with "LiteLLM-") | `LiteLLM` |
| `api_base` | No | Regional API endpoint. US: `https://service.api.aisecurity.paloaltonetworks.com`, EU: `https://service-de.api.aisecurity.paloaltonetworks.com`, India: `https://service-in.api.aisecurity.paloaltonetworks.com`, Singapore: `https://service-sg.api.aisecurity.paloaltonetworks.com` | US |
| `mode` | No | When to run the guardrail (see mode table above) | `pre_call` |
| `fallback_on_error` | No | Action when PANW API is unavailable: `"block"` (fail-closed) or `"allow"` (fail-open). Config errors always block. | `block` |
| `timeout` | No | PANW API call timeout in seconds (recommended: 1-60) | `10.0` |
| `violation_message_template` | No | Custom template for blocked requests. Supports `{guardrail_name}`, `{category}`, `{action_type}`, `{default_message}` placeholders. | - |
| `mask_request_content` | No | Mask sensitive data in prompts instead of blocking | `false` |
| `mask_response_content` | No | Mask sensitive data in responses instead of blocking | `false` |
| `mask_on_block` | No | Backwards-compatible flag that enables both request and response masking | `false` |
| `experimental_use_latest_role_message_only` | No | Anthropic `/v1/messages` only. When unset: scans only latest user message on request side. Set `false` to scan all user/system/developer messages. Non-Anthropic unaffected. | Unset (true for Anthropic) |
### Regional Endpoints
Use the regional `api_base` that matches your Prisma AIRS deployment profile region for lower latency and data residency compliance.
PANW Prisma AIRS supports multiple regional endpoints based on your deployment profile region:
| Region | API Base URL |
|--------|--------------|
| **US** (default) | `https://service.api.aisecurity.paloaltonetworks.com` |
| **EU (Germany)** | `https://service-de.api.aisecurity.paloaltonetworks.com` |
| **India** | `https://service-in.api.aisecurity.paloaltonetworks.com` |
**Example configuration for EU region:**
```yaml
guardrails:
- guardrail_name: "panw-eu"
litellm_params:
guardrail: panw_prisma_airs
api_key: os.environ/PANW_PRISMA_AIRS_API_KEY
api_base: "https://service-de.api.aisecurity.paloaltonetworks.com"
profile_name: "production"
```
:::tip Region Selection
Use the regional endpoint that matches your Prisma AIRS deployment profile region configured in Strata Cloud Manager. Using the correct region ensures:
- Lower latency (requests stay in-region)
- Compliance with data residency requirements
- Optimal performance
:::
## Per-Request Metadata Overrides
You can override guardrail settings on a per-request basis using the `metadata` field:
```json
{
"model": "gpt-4",
"messages": [...],
"metadata": {
"profile_name": "dev-allow-all", // Override profile name
"profile_id": "uuid-here", // Override profile ID (takes precedence)
"user_ip": "192.168.1.100", // Track user IP
"app_name": "MyApp" // Custom app name (becomes "LiteLLM-MyApp")
}
}
```
**Supported Metadata Fields:**
| Field | Description | Priority |
|-------|-------------|----------|
| `profile_name` | PANW AI security profile name | Per-request > config |
| `profile_id` | PANW AI security profile ID (takes precedence over profile_name) | Per-request only |
| `user_ip` | User IP address for tracking in Prisma AIRS | Per-request only |
| `app_name` | Application identifier (prefixed with "LiteLLM-") | Per-request > config > "LiteLLM" |
| `app_user` | Custom user identifier for tracking in Prisma AIRS | `app_user` > `user` > "litellm_user" |
:::info Profile Resolution
- If both `profile_id` and `profile_name` are provided, PANW API uses `profile_id` (it takes precedence)
- If no profile is specified in metadata, uses the config `profile_name`
- If no profile is specified at all, PANW API will use the profile linked to your API key in Strata Cloud Manager
- **Note:** If your API key is not linked to a profile, you must provide `profile_name` or `profile_id`
:::
## Multi-Turn Conversation Tracking
PANW Prisma AIRS automatically tracks multi-turn conversations using LiteLLM's `litellm_trace_id`. This enables you to:
- **Group related requests** - All requests in a conversation share the same AI Session ID in Prisma AIRS SCM logs
- **Track conversation context** - See the full history of prompts and responses for a user session
- **Analyze attack patterns** - Identify sophisticated multi-turn attacks across conversation history
### How It Works
LiteLLM automatically generates a unique `litellm_trace_id` for each conversation session. The PANW guardrail uses this as the PANW transaction ID (which maps to "AI Session ID" in Strata Cloud Manager):
```
Conversation Session: litellm_trace_id = "abc-123-def-456"
Turn 1 (User): "What's the capital of France?"
→ Scan ID: scan_001 | Prisma AIRS AI Session ID: abc-123-def-456
Turn 2 (Assistant): "Paris is the capital of France."
→ Scan ID: scan_002 | Prisma AIRS AI Session ID: abc-123-def-456
Turn 3 (User): "What's the population?"
→ Scan ID: scan_003 | Prisma AIRS AI Session ID: abc-123-def-456
Turn 4 (Assistant): "Paris has approximately 2.1 million residents."
→ Scan ID: scan_004 | Prisma AIRS AI Session ID: abc-123-def-456
```
All scans appear under the same AI Session ID in Prisma AIRS logs, making it easy to:
- Review complete conversation history (all 4 turns grouped together)
- Identify patterns across multiple turns
- Correlate security events within a session
- Track the flow of user prompts and AI responses
### Session Tracking
LiteLLM automatically generates a unique `litellm_trace_id` for each request, which the PANW guardrail uses as the AI Session ID in Strata Cloud Manager. All prompt and response scans for a request are automatically grouped under the same session.
#### Custom Session IDs (Per-App Tracking)
You can provide your own `litellm_trace_id` to track sessions on a per-app or per-conversation basis:
```bash
curl -X POST http://localhost:4000/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer sk-1234" \
-d '{
"model": "gpt-3.5-turbo",
"messages": [{"role": "user", "content": "capital of France"}],
"litellm_trace_id": "my-app-session-123", # Custom AI Session ID
"metadata": {
"profile_name": "dev-allow-all-profile", # Override security profile
"user_ip": "192.168.1.1", # Track user IP
"app_name": "eng" # Custom app identifier
},
"guardrails": ["panw-prisma-airs-pre-guard", "panw-prisma-airs-post-guard"]
}'
```
**Result in PANW SCM:**
- AI Session ID: `my-app-session-123`
- All prompt and response scans will be grouped under this custom session ID
- Perfect for tracking multi-turn conversations or per-application sessions
:::tip Viewing Sessions in Prisma AIRS SCM Logs
In Strata Cloud Manager, navigate to **AI Runtime > Sessions** to view all AI Session IDs and their associated scans. Click on a session to see the complete conversation history with security analysis.
:::
## Environment Variables
### Environment Variables
```bash
export PANW_PRISMA_AIRS_API_KEY="your-panw-api-key"
@@ -348,12 +136,31 @@ export PANW_PRISMA_AIRS_PROFILE_NAME="your-security-profile"
export PANW_PRISMA_AIRS_API_BASE="https://custom-endpoint.com"
```
## Advanced Configuration
### Per-Request Metadata Overrides
| Field | Description | Priority |
|-------|-------------|----------|
| `profile_name` | PANW AI security profile name | Per-request > config |
| `profile_id` | PANW AI security profile ID (takes precedence over `profile_name`) | Per-request only |
| `user_ip` | User IP address for tracking in Prisma AIRS | Per-request only |
| `app_name` | Application identifier (prefixed with "LiteLLM-") | Per-request > config > "LiteLLM" |
| `app_user` | Custom user identifier for tracking in Prisma AIRS | `app_user` > `user` > "litellm_user" |
```json
{
"model": "gpt-4",
"messages": [...],
"metadata": {
"profile_name": "dev-allow-all",
"profile_id": "uuid-here",
"user_ip": "192.168.1.100",
"app_name": "MyApp"
}
}
```
### Multiple Security Profiles
You can configure different security profiles for different use cases:
```yaml
guardrails:
- guardrail_name: "panw-strict-security"
@@ -361,126 +168,40 @@ guardrails:
guardrail: panw_prisma_airs
mode: "pre_call"
api_key: os.environ/PANW_PRISMA_AIRS_API_KEY
profile_name: "strict-policy" # High security profile
- guardrail_name: "panw-permissive-security"
profile_name: "strict-policy"
- guardrail_name: "panw-permissive-security"
litellm_params:
guardrail: panw_prisma_airs
mode: "post_call"
api_key: os.environ/PANW_PRISMA_AIRS_API_KEY
profile_name: "permissive-policy" # Lower security profile
profile_name: "permissive-policy"
```
### Multiple API Keys (Multi-Tenant)
For multi-tenant deployments where different customers need different PANW API keys, create separate guardrail instances:
```yaml
guardrails:
- guardrail_name: "panw-customer-a"
litellm_params:
guardrail: panw_prisma_airs
mode: "pre_call"
api_key: os.environ/PANW_CUSTOMER_A_KEY # Linked to Customer A profile in SCM
- guardrail_name: "panw-customer-b"
litellm_params:
guardrail: panw_prisma_airs
mode: "pre_call"
api_key: os.environ/PANW_CUSTOMER_B_KEY # Linked to Customer B profile in SCM
```
Then route requests to the appropriate guardrail:
```bash
curl -X POST http://localhost:4000/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer sk-1234" \
-d '{
"model": "gpt-4",
"messages": [{"role": "user", "content": "Hello"}],
"guardrails": ["panw-customer-a"]
}'
```
**Use Cases:**
- **Multi-tenant deployments**: Different customers with different security policies
- **Environment-specific policies**: Dev/staging/prod with different API keys and profiles
- **A/B testing**: Compare different security profiles side-by-side
### Content Masking
PANW Prisma AIRS can automatically mask sensitive content (PII, credit cards, SSNs, etc.) instead of blocking requests. This allows your application to continue functioning while protecting sensitive data.
#### How It Works
1. **Detection**: PANW scans content and identifies sensitive data
2. **Masking**: Sensitive data is replaced with placeholders (e.g., `XXXXXXXXXX` or `{PHONE}`)
3. **Pass-through**: Masked content is sent to the LLM or returned to the user
#### Configuration Options
:::warning Important: Masking is Controlled by PANW Security Profile
The actual masking behavior (what content gets masked and how) is controlled by your PANW Prisma AIRS security profile in Strata Cloud Manager. The LiteLLM flags (`mask_request_content`, `mask_response_content`) only control whether to apply the masked content and allow the request to continue, or block entirely.
:::
```yaml
guardrails:
- guardrail_name: "panw-with-masking"
litellm_params:
guardrail: panw_prisma_airs
mode: "post_call" # Scan response output
mode: "post_call"
api_key: os.environ/PANW_PRISMA_AIRS_API_KEY
profile_name: "default"
mask_request_content: true # Mask sensitive data in prompts
mask_response_content: true # Mask sensitive data in responses
mask_request_content: true
mask_response_content: true
```
**Masking Parameters:**
- `mask_request_content: true` - When PANW detects sensitive data in prompts, mask it instead of blocking
- `mask_response_content: true` - When PANW detects sensitive data in responses, mask it instead of blocking
- `mask_on_block: true` - Backwards compatible flag that enables both request and response masking
:::warning Important: Masking is Controlled by PANW Security Profile
The **actual masking behavior** (what content gets masked and how) is controlled by your **PANW Prisma AIRS security profile** configured in Strata Cloud Manager. The LiteLLM config settings (`mask_request_content`, `mask_response_content`) only control whether to:
- **Apply the masked content** returned by PANW and allow the request to continue, OR
- **Block the request** entirely when sensitive data is detected
LiteLLM does not alter or configure your PANW security profile. To change what content gets masked, update your profile settings in Strata Cloud Manager.
:::
:::info Security Posture
The guardrail is **fail-closed** by default - if the PANW API is unavailable, requests are blocked to ensure no unscanned content reaches your LLM. This provides maximum security.
:::
### Custom Violation Messages
You can customize the error message returned to the user when a request is blocked by configuring the `violation_message_template` parameter. This is useful for providing user-friendly feedback instead of technical details.
```yaml
guardrails:
- guardrail_name: "panw-custom-message"
litellm_params:
guardrail: panw_prisma_airs
api_key: os.environ/PANW_PRISMA_AIRS_API_KEY
# Simple message
violation_message_template: "Your request was blocked by our AI Security Policy."
- guardrail_name: "panw-detailed-message"
litellm_params:
guardrail: panw_prisma_airs
api_key: os.environ/PANW_PRISMA_AIRS_API_KEY
# Message with placeholders
violation_message_template: "{action_type} blocked due to {category} violation. Please contact support."
```
**Supported Placeholders:**
- `{guardrail_name}`: Name of the guardrail (e.g. "panw-custom-message")
- `{category}`: Violation category (e.g. "malicious", "injection", "dlp")
- `{action_type}`: "Prompt" or "Response"
- `{default_message}`: The original technical error message
- `mask_request_content: true` — mask sensitive data in prompts instead of blocking
- `mask_response_content: true` — mask sensitive data in responses instead of blocking
- `mask_on_block: true` — backwards-compatible flag that enables both request and response masking
### Fail-Open Configuration
By default, the PANW guardrail operates in **fail-closed** mode for maximum security. If the PANW API is unavailable (timeout, rate limit, network error), requests are blocked. You can configure **fail-open** mode for high-availability scenarios where service continuity is critical.
```yaml
guardrails:
- guardrail_name: "panw-high-availability"
@@ -488,135 +209,86 @@ guardrails:
guardrail: panw_prisma_airs
api_key: os.environ/PANW_PRISMA_AIRS_API_KEY
profile_name: "production"
fallback_on_error: "allow" # Enable fail-open mode
timeout: 5.0 # Shorter timeout for fail-open
fallback_on_error: "allow"
timeout: 5.0
```
**Configuration Options:**
| Parameter | Value | Behavior |
|-----------|-------|----------|
| `fallback_on_error` | `"block"` (default) | **Fail-closed**: Block requests when API unavailable (maximum security) |
| `fallback_on_error` | `"allow"` | **Fail-open**: Allow requests when API unavailable (high availability) |
| `timeout` | `1.0` - `60.0` | API call timeout in seconds (default: `10.0`) |
**Error Handling Matrix:**
| Error Type | `fallback_on_error="block"` | `fallback_on_error="allow"` |
|------------|----------------------------|----------------------------|
| 401 Unauthorized | Block (500) | Block (500) ⚠️ |
| 403 Forbidden | Block (500) | Block (500) ⚠️ |
| Profile Error | Block (500) | Block (500) ⚠️ |
| 401 Unauthorized | Block (500) | Block (500) |
| 403 Forbidden | Block (500) | Block (500) |
| Profile Error | Block (500) | Block (500) |
| 429 Rate Limit | Block (500) | Allow (`:unscanned`) |
| Timeout | Block (500) | Allow (`:unscanned`) |
| Network Error | Block (500) | Allow (`:unscanned`) |
| 5xx Server Error | Block (500) | Allow (`:unscanned`) |
| Content Blocked | Block (400) | Block (400) |
⚠️ = Always blocks regardless of fail-open setting
Authentication and configuration errors (401, 403, invalid profile) always block. Only transient errors (429, timeout, network) trigger fail-open.
:::warning Security Trade-Off
Enabling `fallback_on_error="allow"` reduces security in exchange for availability. Requests may proceed **without scanning** when the PANW API is unavailable. Use only when:
- Service availability is more critical than security scanning
- You have other security controls in place
- You monitor the `:unscanned` header for audit trails
When fail-open is triggered, the response includes a tracking header: `X-LiteLLM-Applied-Guardrails: panw-airs:unscanned`
**Authentication and configuration errors (401, 403, invalid profile) always block** - only transient errors (429, timeout, network) trigger fail-open behavior.
:::
**Observability:**
When fail-open is triggered, the response includes a special header for tracking:
```
X-LiteLLM-Applied-Guardrails: panw-airs:unscanned
```
This allows you to:
- Track which requests bypassed scanning
- Alert on unscanned request volumes
- Audit compliance requirements
#### Example: Masking Credit Card Numbers
<Tabs>
<TabItem label="Without Masking" value="no-mask">
**Request:**
```json
{
"messages": [
{"role": "user", "content": "My credit card is 4929-3813-3266-4295"}
]
}
```
**Response:** ❌ **Blocked with 400 error**
</TabItem>
<TabItem label="With Masking" value="with-mask">
**Request:**
```json
{
"messages": [
{"role": "user", "content": "My credit card is 4929-3813-3266-4295"}
]
}
```
**Masked prompt sent to LLM:**
```json
{
"messages": [
{"role": "user", "content": "My credit card is XXXXXXXXXXXXXXXXXX"}
]
}
```
**Response:** ✅ **Allowed with masked content**
</TabItem>
</Tabs>
#### Masking Capabilities
The guardrail masks sensitive content in:
- ✅ **Chat messages** - User prompts and assistant responses
- ✅ **Streaming responses** - Real-time masking of streamed content
- ✅ **Multi-choice responses** - All choices in the response
- ✅ **Tool/function calls** - Arguments passed to tools and functions
- ✅ **Content lists** - Mixed content types (text, images, etc.)
#### Complete Example
### Custom Violation Messages
```yaml
guardrails:
- guardrail_name: "panw-production-security"
- guardrail_name: "panw-custom-message"
litellm_params:
guardrail: panw_prisma_airs
mode: "post_call" # Scan input and output
api_key: os.environ/PANW_PRISMA_AIRS_API_KEY
profile_name: "production-profile"
mask_request_content: true # Mask sensitive prompts
mask_response_content: true # Mask sensitive responses
violation_message_template: "Your request was blocked by our AI Security Policy."
- guardrail_name: "panw-detailed-message"
litellm_params:
guardrail: panw_prisma_airs
api_key: os.environ/PANW_PRISMA_AIRS_API_KEY
violation_message_template: "{action_type} blocked due to {category} violation. Please contact support."
```
## Use Cases
**Supported Placeholders:** `{guardrail_name}`, `{category}`, `{action_type}`, `{default_message}`
From [official Prisma AIRS documentation](https://docs.paloaltonetworks.com/ai-runtime-security/activation-and-onboarding/ai-runtime-security-api-intercept-overview):
## Behavior and Limitations
- **Secure AI models in production**: Validate prompt requests and responses to protect deployed AI models
- **Detect data poisoning**: Identify contaminated training data before fine-tuning
- **Protect against adversarial input**: Safeguard AI agents from malicious inputs and outputs
- **Prevent sensitive data leakage**: Use API-based threat detection to block sensitive data leaks
### Transaction Tracking
For standard request/response scans, `tr_id` maps to `litellm_call_id`. MCP tool scans use the parent `litellm_call_id` when available; if missing, PANW synthesizes a fallback MCP transaction ID. The real limitation is correlation loss — synthesized MCP `tr_id` values are not grouped with the parent request's prompt/response scans in AIRS dashboards.
By default, LiteLLM generates a UUID for `litellm_call_id`. To provide your own:
```bash
curl -X POST http://localhost:4000/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer sk-1234" \
-H "x-litellm-call-id: my-custom-call-id-789" \
-d '{
"model": "gpt-3.5-turbo",
"messages": [{"role": "user", "content": "capital of France"}],
"guardrails": ["panw-prisma-airs-guardrail"]
}'
```
The `x-litellm-call-id` is also returned in response headers. If you pass `litellm_trace_id` in request metadata (or via the `x-litellm-trace-id` header), it is included in the PANW API payload metadata but does not affect `tr_id` or appear in Prisma AIRS.
### Streaming
- Response masking works on OpenAI chat streaming (`mask_response_content: true`)
- `/v1/messages` and `/v1/responses` raw streaming blocks instead of masking when violations are detected
- Request-side masking (`mask_request_content`) is unaffected by endpoint type
- When `fallback_on_error: "allow"` is set, streaming responses fail open on transient PANW API errors (timeout, 5xx, network) — original chunks are yielded unchanged
## MCP Tool Security
Tool invocations are sent to AIRS as structured `tool_event` payloads containing tool name, ecosystem, and serialized arguments. Tool-event scans always use request mode.
**What is scanned:** LLM-driven `tool_calls` (name + arguments) and MCP request-side invocations when `mcp_tool_name` (or fallback `name`) is present. Response-side OpenAI-compatible `tool_calls` are also scanned when surfaced into `apply_guardrail()`.
**What is not scanned:** Tool definitions in `inputs["tools"]` and post-MCP tool results (no `post_mcp_call` hook exists yet).
## Next Steps
### Current Limitations
- Configure your security policies in [Strata Cloud Manager](https://apps.paloaltonetworks.com/)
- Review the [Prisma AIRS API documentation](https://pan.dev/airs/) for advanced features
- Set up monitoring and alerting for threat detections in your PANW dashboard
- Consider implementing both pre_call and post_call guardrails for comprehensive protection
- Monitor detection events and tune your security profiles based on your application needs
- **No post-MCP response scanning.** Actual post-MCP tool-result scanning is not supported because there is no `post_mcp_call` hook in the framework. Response-side MCP events are only scanned when they appear as regular `tool_calls` in the LLM response.
- **Guardrail selection not inherited by MCP sub-calls.** With `default_on: false`, MCP request-side child-call scans can be skipped because the parent request's guardrail selection is not propagated to the synthetic MCP payload. Workaround: use a dedicated guardrail with `mode: pre_mcp_call` and `default_on: true`.
- **MCP transaction correlation.** MCP tool scans use the parent `litellm_call_id` when available; otherwise a fallback ID is synthesized and will not be grouped with the parent request in AIRS dashboards.
@@ -0,0 +1,13 @@
-- SkipTransactionBlock
-- Drop invalid indexes left behind by failed CONCURRENTLY builds
DROP INDEX CONCURRENTLY IF EXISTS "LiteLLM_VerificationToken_key_alias_idx";
-- CreateIndex
CREATE INDEX CONCURRENTLY "LiteLLM_VerificationToken_key_alias_idx" ON "LiteLLM_VerificationToken"("key_alias");
-- Drop invalid indexes left behind by failed CONCURRENTLY builds
DROP INDEX CONCURRENTLY IF EXISTS "LiteLLM_SpendLogs_user_startTime_idx";
-- CreateIndex
CREATE INDEX CONCURRENTLY "LiteLLM_SpendLogs_user_startTime_idx" ON "LiteLLM_SpendLogs"("user", "startTime");
@@ -388,6 +388,9 @@ model LiteLLM_VerificationToken {
// SELECT ... FROM "public"."LiteLLM_VerificationToken" WHERE (("public"."LiteLLM_VerificationToken"."expires" IS NULL OR "public"."LiteLLM_VerificationToken"."expires" > $1) AND "public"."LiteLLM_VerificationToken"."budget_reset_at" < $2) OFFSET $3
@@index([budget_reset_at, expires])
// SELECT ... FROM "public"."LiteLLM_VerificationToken" WHERE (...) ORDER BY "public"."LiteLLM_VerificationToken"."key_alias" ASC
@@index([key_alias])
}
model LiteLLM_JWTKeyMapping {
@@ -553,6 +556,9 @@ model LiteLLM_SpendLogs {
@@index([startTime, request_id])
@@index([end_user])
@@index([session_id])
// SELECT ... FROM "LiteLLM_SpendLogs" WHERE ("startTime" >= $1 AND "startTime" <= $2 AND "user" = $3) GROUP BY ...
@@index([user, startTime])
}
// View spend, model, api_key per request
-4
View File
@@ -346,8 +346,6 @@ class DualCache(BaseCache):
)
try:
if self.in_memory_cache is not None:
if "ttl" not in kwargs and self.default_in_memory_ttl is not None:
kwargs["ttl"] = self.default_in_memory_ttl
await self.in_memory_cache.async_set_cache(key, value, **kwargs)
if self.redis_cache is not None and local_only is False:
@@ -369,8 +367,6 @@ class DualCache(BaseCache):
)
try:
if self.in_memory_cache is not None:
if "ttl" not in kwargs and self.default_in_memory_ttl is not None:
kwargs["ttl"] = self.default_in_memory_ttl
await self.in_memory_cache.async_set_cache_pipeline(
cache_list=cache_list, **kwargs
)
@@ -390,7 +390,6 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
ResponseOutputMessage,
ResponseReasoningItem,
)
from openai.types.responses.response_output_item import ResponseApplyPatchToolCall
from litellm.types.utils import Choices, Message
@@ -449,18 +448,6 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
accumulated_tool_calls.append(tool_call_dict)
tool_call_index += 1
elif isinstance(item, ResponseApplyPatchToolCall):
from litellm.responses.litellm_completion_transformation.transformation import (
LiteLLMCompletionResponsesConfig,
)
tool_call_dict = LiteLLMCompletionResponsesConfig.convert_apply_patch_tool_call_to_chat_completion_tool_call(
tool_call_item=item,
index=tool_call_index,
)
accumulated_tool_calls.append(tool_call_dict)
tool_call_index += 1
elif isinstance(item, dict) and handle_raw_dict_callback is not None:
# Handle raw dict responses (e.g., from GPT-5 Codex)
choice, index = handle_raw_dict_callback(item=item, index=index)
@@ -1108,12 +1095,6 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
finish_reason = "tool_calls" if has_function_calls else "stop"
usage = None
if response_data.get("usage"):
from litellm.responses.utils import ResponseAPILoggingUtils
usage = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(
response_data.get("usage")
)
return ModelResponseStream(
choices=[
StreamingChoices(
@@ -1121,8 +1102,7 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
delta=Delta(content=""),
finish_reason=finish_reason,
)
],
usage=usage
]
)
else:
pass
+1 -5
View File
@@ -1214,12 +1214,8 @@ OPENAI_FINISH_REASONS = [
"stop",
"length",
"function_call",
"tool_calls",
"content_filter",
"null",
"finish_reason_unspecified",
"malformed_function_call",
"guardrail_intervened",
"eos",
]
HUMANLOOP_PROMPT_CACHE_TTL_SECONDS = int(
os.getenv("HUMANLOOP_PROMPT_CACHE_TTL_SECONDS", 60)
@@ -770,8 +770,6 @@ class GoogleGenAIAdapter:
"content_filter": "SAFETY",
"tool_calls": "STOP",
"function_call": "STOP",
"finish_reason_unspecified": "FINISH_REASON_UNSPECIFIED",
"malformed_function_call": "MALFORMED_FUNCTION_CALL",
}
return mapping.get(finish_reason, "STOP")
+50 -40
View File
@@ -1,11 +1,11 @@
# What is this?
## Helper utilities
from typing import TYPE_CHECKING, Any, Iterable, List, Literal, Optional, Union
from typing import TYPE_CHECKING, Any, Iterable, List, Literal, Optional, Union, get_args
import httpx
from litellm._logging import verbose_logger
from litellm.types.llms.openai import AllMessageValues
from litellm.types.llms.openai import AllMessageValues, OpenAIChatCompletionFinishReason
if TYPE_CHECKING:
from opentelemetry.trace import Span as _Span
@@ -58,45 +58,55 @@ def safe_divide(
return numerator / denominator
def map_finish_reason(
finish_reason: str,
): # openai supports 5 stop sequences - 'stop', 'length', 'function_call', 'content_filter', 'null'
# anthropic mapping
if finish_reason == "stop_sequence":
_FINISH_REASON_MAP: dict[str, OpenAIChatCompletionFinishReason] = {
# Anthropic
"stop_sequence": "stop",
"end_turn": "stop",
"max_tokens": "length",
"tool_use": "tool_calls",
"compaction": "length",
# Cohere
"COMPLETE": "stop",
"ERROR_TOXIC": "content_filter",
"ERROR": "stop",
# HuggingFace / Together AI
"eos_token": "stop",
"eos": "stop",
# Gemini / Vertex AI
"STOP": "stop",
"MAX_TOKENS": "length",
"SAFETY": "content_filter",
"RECITATION": "content_filter",
"FINISH_REASON_UNSPECIFIED": "stop",
"MALFORMED_FUNCTION_CALL": "stop",
"LANGUAGE": "content_filter",
"OTHER": "content_filter",
"BLOCKLIST": "content_filter",
"PROHIBITED_CONTENT": "content_filter",
"SPII": "content_filter",
"IMAGE_SAFETY": "content_filter",
"IMAGE_PROHIBITED_CONTENT": "content_filter",
"TOO_MANY_TOOL_CALLS": "stop",
"MALFORMED_RESPONSE": "stop",
# Bedrock
"guardrail_intervened": "content_filter",
# OpenAI passthrough
"stop": "stop",
"length": "length",
"tool_calls": "tool_calls",
"function_call": "function_call",
"content_filter": "content_filter",
}
def map_finish_reason(finish_reason: str) -> OpenAIChatCompletionFinishReason:
mapped = _FINISH_REASON_MAP.get(finish_reason)
if mapped is None:
verbose_logger.warning(
"Unmapped finish_reason '%s', defaulting to 'stop'", finish_reason
)
return "stop"
# cohere mapping - https://docs.cohere.com/reference/generate
elif finish_reason == "COMPLETE":
return "stop"
elif finish_reason == "MAX_TOKENS": # cohere + vertex ai
return "length"
elif finish_reason == "ERROR_TOXIC":
return "content_filter"
elif (
finish_reason == "ERROR"
): # openai currently doesn't support an 'error' finish reason
return "stop"
# huggingface mapping https://huggingface.github.io/text-generation-inference/#/Text%20Generation%20Inference/generate_stream
elif finish_reason == "eos_token" or finish_reason == "stop_sequence":
return "stop"
elif (
finish_reason == "FINISH_REASON_UNSPECIFIED"
): # vertex ai - got from running `print(dir(response_obj.candidates[0].finish_reason))`: ['FINISH_REASON_UNSPECIFIED', 'MAX_TOKENS', 'OTHER', 'RECITATION', 'SAFETY', 'STOP',]
return "finish_reason_unspecified"
elif finish_reason == "MALFORMED_FUNCTION_CALL":
return "malformed_function_call"
elif finish_reason == "SAFETY" or finish_reason == "RECITATION": # vertex ai
return "content_filter"
elif finish_reason == "STOP": # vertex ai
return "stop"
elif finish_reason == "end_turn" or finish_reason == "stop_sequence": # anthropic
return "stop"
elif finish_reason == "max_tokens": # anthropic
return "length"
elif finish_reason == "tool_use": # anthropic
return "tool_calls"
elif finish_reason == "compaction":
return "length"
return finish_reason
return mapped
def remove_index_from_tool_calls(
@@ -64,10 +64,12 @@ def duration_in_seconds(duration: str) -> int:
now = time.time()
current_time = datetime.fromtimestamp(now)
# Calculate target month and year, handling overflow past December
total_months = current_time.month - 1 + value # 0-indexed months
target_year = current_time.year + total_months // 12
target_month = total_months % 12 + 1 # back to 1-indexed
if current_time.month == 12:
target_year = current_time.year + 1
target_month = 1
else:
target_year = current_time.year
target_month = current_time.month + value
# Determine the day to set for next month
target_day = current_time.day
@@ -11,7 +11,7 @@ export LITELLM_LOCAL_MODEL_COST_MAP=True
import json
import os
from importlib.resources import files
from typing import Optional
from typing import Dict, List, Optional
import httpx
@@ -183,6 +183,61 @@ def get_model_cost_map_source_info() -> dict:
}
def _expand_model_aliases(model_cost: dict) -> dict:
"""
Expand ``aliases`` lists in model cost entries into top-level entries.
Each alias gets a reference to the **same** dict object as the canonical
entry (zero memory overhead). The ``aliases`` key is removed from the
entry so downstream code never sees it.
If an alias collides with an existing canonical entry the alias is
skipped and a warning is logged.
"""
aliases_to_add: Dict[str, dict] = {}
keys_with_aliases: List[str] = []
for model_name, model_info in model_cost.items():
aliases: Optional[list] = model_info.get("aliases")
if aliases is None:
continue
keys_with_aliases.append(model_name)
if not isinstance(aliases, list):
verbose_logger.warning(
"LiteLLM model alias field for '%s' is not a list (got %s) — skipping.",
model_name,
type(aliases).__name__,
)
continue
if not aliases:
continue
for alias in aliases:
if alias in model_cost:
verbose_logger.warning(
"LiteLLM model alias conflict: alias '%s' (from '%s') "
"already exists as a canonical entry — skipping.",
alias,
model_name,
)
continue
if alias in aliases_to_add:
verbose_logger.warning(
"LiteLLM model alias conflict: alias '%s' (from '%s') "
"was already claimed by another entry — skipping.",
alias,
model_name,
)
continue
aliases_to_add[alias] = model_info # same dict reference
# Remove the ``aliases`` key from entries so it doesn't pollute model info
for key in keys_with_aliases:
model_cost[key].pop("aliases", None)
model_cost.update(aliases_to_add)
return model_cost
def get_model_cost_map(url: str) -> dict:
"""
Public entry point — returns the model cost map dict.
@@ -202,7 +257,7 @@ def get_model_cost_map(url: str) -> dict:
_cost_map_source_info.url = None
_cost_map_source_info.is_env_forced = True
_cost_map_source_info.fallback_reason = None
return GetModelCostMap.load_local_model_cost_map()
return _expand_model_aliases(GetModelCostMap.load_local_model_cost_map())
_cost_map_source_info.url = url
_cost_map_source_info.is_env_forced = False
@@ -218,7 +273,7 @@ def get_model_cost_map(url: str) -> dict:
)
_cost_map_source_info.source = "local"
_cost_map_source_info.fallback_reason = f"Remote fetch failed: {str(e)}"
return GetModelCostMap.load_local_model_cost_map()
return _expand_model_aliases(GetModelCostMap.load_local_model_cost_map())
# Validate using cached count (cheap int comparison, no file I/O)
if not GetModelCostMap.validate_model_cost_map(
@@ -232,8 +287,8 @@ def get_model_cost_map(url: str) -> dict:
)
_cost_map_source_info.source = "local"
_cost_map_source_info.fallback_reason = "Remote data failed integrity validation"
return GetModelCostMap.load_local_model_cost_map()
return _expand_model_aliases(GetModelCostMap.load_local_model_cost_map())
_cost_map_source_info.source = "remote"
_cost_map_source_info.fallback_reason = None
return content
return _expand_model_aliases(content)
@@ -2493,74 +2493,257 @@ def anthropic_messages_pt( # noqa: PLR0915
assistant_content.extend(_compaction_blocks) # type: ignore
thinking_blocks = assistant_content_block.get("thinking_blocks", None)
# Check if tool_calls contain server tool calls (web search, etc.)
# If so, we need to interleave thinking blocks with tool call groups
# to preserve the original content block ordering.
# Fixes: https://github.com/BerriAI/litellm/issues/23047
assistant_tool_calls = assistant_content_block.get("tool_calls")
_has_server_tool_calls = False
if assistant_tool_calls is not None:
for _tc in assistant_tool_calls:
_tc_id = (
_tc.get("id")
if isinstance(_tc, dict)
else getattr(_tc, "id", None)
)
if _tc_id and isinstance(_tc_id, str) and _tc_id.startswith("srvtoolu_"):
_has_server_tool_calls = True
break
if (
thinking_blocks is not None
): # IMPORTANT: ADD THIS FIRST, ELSE ANTHROPIC WILL RAISE AN ERROR
assistant_content.extend(thinking_blocks)
if "content" in assistant_content_block and isinstance(
assistant_content_block["content"], list
and _has_server_tool_calls
and isinstance(
assistant_content_block.get("content", None), (str, type(None))
)
):
for m in assistant_content_block["content"]:
# handle thinking blocks
thinking_block = cast(str, m.get("thinking", ""))
text_block = cast(str, m.get("text", ""))
if (
m.get("type", "") == "thinking" and len(thinking_block) > 0
): # don't pass empty text blocks. anthropic api raises errors.
anthropic_message: Union[
ChatCompletionThinkingBlock,
AnthropicMessagesTextParam,
] = cast(ChatCompletionThinkingBlock, m)
assistant_content.append(anthropic_message)
# handle text
elif (
m.get("type", "") == "text" and len(text_block) > 0
): # don't pass empty text blocks. anthropic api raises errors.
anthropic_message = AnthropicMessagesTextParam(
type="text", text=text_block
)
_cached_message = add_cache_control_to_content(
anthropic_content_element=anthropic_message,
original_content_element=dict(m),
)
# INTERLEAVED MODE: When we have both thinking blocks and server
# tool calls (e.g. web search), Anthropic's original response
# interleaves them: [thinking_1, server_tool_use_1, result_1,
# thinking_2, text, server_tool_use_2, result_2, ...].
# We must preserve this interleaved order because Anthropic
# verifies thinking block signatures based on position.
assistant_content.append(
cast(AnthropicMessagesTextParam, _cached_message)
)
# handle server_tool_use blocks (tool search, web search, etc.)
# Pass through as-is since these are Anthropic-native content types
elif m.get("type", "") == "server_tool_use":
assistant_content.append(m) # type: ignore
# handle all *_tool_result blocks (tool_search_tool_result,
# web_search_tool_result, bash_code_execution_tool_result, etc.)
# Pass through as-is since these are Anthropic-native content types
elif m.get("type", "").endswith("_tool_result"):
assistant_content.append(m) # type: ignore
elif (
"content" in assistant_content_block
and isinstance(assistant_content_block["content"], str)
and assistant_content_block[
"content"
] # don't pass empty text blocks. anthropic api raises errors.
):
_anthropic_text_content_element = AnthropicMessagesTextParam(
type="text",
text=assistant_content_block["content"],
# Build the tool call groups (server_tool_use + its result)
_provider_specific_fields_raw_tc = assistant_content_block.get(
"provider_specific_fields"
)
_provider_specific_fields_tc: Dict[str, Any] = {}
if isinstance(_provider_specific_fields_raw_tc, dict):
_provider_specific_fields_tc = cast(
Dict[str, Any], _provider_specific_fields_raw_tc
)
_web_search_results_tc = _provider_specific_fields_tc.get(
"web_search_results"
)
_tool_results_tc = _provider_specific_fields_tc.get("tool_results")
tool_invoke_results = convert_to_anthropic_tool_invoke(
assistant_tool_calls, # type: ignore
web_search_results=_web_search_results_tc,
tool_results=_tool_results_tc,
)
_content_element = add_cache_control_to_content(
anthropic_content_element=_anthropic_text_content_element,
original_content_element=dict(assistant_content_block),
# Group tool invoke results into (server_tool_use, result) pairs
# and separate regular tool_use blocks
server_tool_groups: List[List[Any]] = []
regular_tool_uses: List[Any] = []
_current_group: List[Any] = []
for item in tool_invoke_results:
item_type = (
item.get("type", "")
if isinstance(item, dict)
else getattr(item, "type", "")
)
if item_type == "server_tool_use":
if _current_group:
server_tool_groups.append(_current_group)
_current_group = [item]
elif item_type.endswith("_tool_result"):
_current_group.append(item)
elif item_type == "tool_use":
regular_tool_uses.append(item)
else:
_current_group.append(item)
if _current_group:
server_tool_groups.append(_current_group)
# Build the text block if content is a non-empty string
text_element = None
if (
isinstance(assistant_content_block.get("content"), str)
and assistant_content_block["content"]
):
_anthropic_text_content_element = AnthropicMessagesTextParam(
type="text",
text=assistant_content_block["content"],
)
_content_element = add_cache_control_to_content(
anthropic_content_element=_anthropic_text_content_element,
original_content_element=dict(assistant_content_block),
)
if "cache_control" in _content_element:
_anthropic_text_content_element["cache_control"] = (
_content_element["cache_control"]
)
text_element = _anthropic_text_content_element
# Interleave: each thinking block precedes its server tool group.
# Pattern: thinking[0], group[0], thinking[1], group[1], ...
# Any remaining thinking blocks (after all groups) go before text.
# Any remaining groups (after all thinking blocks) go after.
tb_idx = 0
grp_idx = 0
num_tb = len(thinking_blocks) if thinking_blocks else 0
num_grp = len(server_tool_groups)
while tb_idx < num_tb or grp_idx < num_grp:
if tb_idx < num_tb and grp_idx < num_grp:
# Emit thinking block then its tool group
assistant_content.append(thinking_blocks[tb_idx])
tb_idx += 1
for block in server_tool_groups[grp_idx]:
item_id = (
block.get("id")
if isinstance(block, dict)
else getattr(block, "id", None)
)
if item_id and item_id in unique_tool_ids:
continue
if item_id:
unique_tool_ids.add(item_id)
assistant_content.append(
cast(AnthropicMessagesAssistantMessageValues, block)
)
grp_idx += 1
elif tb_idx < num_tb:
# More thinking blocks than tool groups - emit before text
assistant_content.append(thinking_blocks[tb_idx])
tb_idx += 1
else:
# More tool groups than thinking blocks - emit remaining
for block in server_tool_groups[grp_idx]:
item_id = (
block.get("id")
if isinstance(block, dict)
else getattr(block, "id", None)
)
if item_id and item_id in unique_tool_ids:
continue
if item_id:
unique_tool_ids.add(item_id)
assistant_content.append(
cast(AnthropicMessagesAssistantMessageValues, block)
)
grp_idx += 1
# Add text block (if any)
if text_element is not None:
assistant_content.append(text_element)
# Add regular (non-server) tool calls at the end
for item in regular_tool_uses:
item_id = (
item.get("id")
if isinstance(item, dict)
else getattr(item, "id", None)
)
if item_id and item_id in unique_tool_ids:
continue
if item_id:
unique_tool_ids.add(item_id)
assistant_content.append(
cast(AnthropicMessagesAssistantMessageValues, item)
)
# Mark tool_calls as already processed so they are not added again
assistant_tool_calls = None
else:
# SEQUENTIAL MODE: No server tool calls, or no thinking blocks,
# or content is a list. Use the original sequential approach.
# When content is a list, check if it already contains thinking
# blocks inline. If so, skip prepending thinking_blocks to avoid
# duplication and preserve the original interleaved order.
# Fixes the gap where list-content messages bypass INTERLEAVED
# MODE and still get thinking blocks prepended out of order.
_content_is_list = "content" in assistant_content_block and isinstance(
assistant_content_block["content"], list
)
_list_has_thinking = False
if _content_is_list:
for _item in assistant_content_block["content"]:
if isinstance(_item, dict) and _item.get("type") in ("thinking", "redacted_thinking"):
_list_has_thinking = True
break
if "cache_control" in _content_element:
_anthropic_text_content_element["cache_control"] = _content_element[
"cache_control"
]
if (
thinking_blocks is not None
and not _list_has_thinking
): # IMPORTANT: ADD THIS FIRST, ELSE ANTHROPIC WILL RAISE AN ERROR
assistant_content.extend(thinking_blocks)
if _content_is_list:
for m in assistant_content_block["content"]:
# handle thinking blocks
thinking_block = cast(str, m.get("thinking", ""))
text_block = cast(str, m.get("text", ""))
if (
m.get("type", "") == "thinking" and len(thinking_block) > 0
): # don't pass empty text blocks. anthropic api raises errors.
anthropic_message: Union[
ChatCompletionThinkingBlock,
AnthropicMessagesTextParam,
] = cast(ChatCompletionThinkingBlock, m)
assistant_content.append(anthropic_message)
# handle text
elif (
m.get("type", "") == "text" and len(text_block) > 0
): # don't pass empty text blocks. anthropic api raises errors.
anthropic_message = AnthropicMessagesTextParam(
type="text", text=text_block
)
_cached_message = add_cache_control_to_content(
anthropic_content_element=anthropic_message,
original_content_element=dict(m),
)
assistant_content.append(_anthropic_text_content_element)
assistant_content.append(
cast(AnthropicMessagesTextParam, _cached_message)
)
# handle server_tool_use blocks (tool search, web search, etc.)
# Pass through as-is since these are Anthropic-native content types
elif m.get("type", "") == "server_tool_use":
assistant_content.append(m) # type: ignore
# handle all *_tool_result blocks (tool_search_tool_result,
# web_search_tool_result, bash_code_execution_tool_result, etc.)
# Pass through as-is since these are Anthropic-native content types
elif m.get("type", "").endswith("_tool_result"):
assistant_content.append(m) # type: ignore
elif (
"content" in assistant_content_block
and isinstance(assistant_content_block["content"], str)
and assistant_content_block[
"content"
] # don't pass empty text blocks. anthropic api raises errors.
):
_anthropic_text_content_element = AnthropicMessagesTextParam(
type="text",
text=assistant_content_block["content"],
)
_content_element = add_cache_control_to_content(
anthropic_content_element=_anthropic_text_content_element,
original_content_element=dict(assistant_content_block),
)
if "cache_control" in _content_element:
_anthropic_text_content_element["cache_control"] = _content_element[
"cache_control"
]
assistant_content.append(_anthropic_text_content_element)
assistant_tool_calls = assistant_content_block.get("tool_calls")
if (
assistant_tool_calls is not None
): # support assistant tool invoke conversion
@@ -73,53 +73,6 @@ def _redact_responses_api_output(output_items):
summary_item.text = "redacted-by-litellm"
def _redact_standard_logging_object(model_call_details: dict):
"""Redact messages and response inside standard_logging_object if present."""
standard_logging_object = model_call_details.get("standard_logging_object")
if standard_logging_object is None:
return
redacted_str = "redacted-by-litellm"
if standard_logging_object.get("messages") is not None:
standard_logging_object["messages"] = [
{"role": "user", "content": redacted_str}
]
response = standard_logging_object.get("response")
if response is not None:
if isinstance(response, dict) and "output" in response:
# ResponsesAPIResponse format - redact content in output items
if isinstance(response.get("output"), list):
for output_item in response["output"]:
if isinstance(output_item, dict) and "content" in output_item:
if isinstance(output_item["content"], list):
for content_item in output_item["content"]:
if (
isinstance(content_item, dict)
and "text" in content_item
):
content_item["text"] = redacted_str
elif isinstance(response, dict) and "choices" in response:
# ModelResponse dict format - redact content in choices
if isinstance(response.get("choices"), list):
for choice in response["choices"]:
if isinstance(choice, dict):
if "message" in choice and isinstance(choice["message"], dict):
choice["message"]["content"] = redacted_str
if "audio" in choice["message"]:
choice["message"]["audio"] = None
elif "delta" in choice and isinstance(choice["delta"], dict):
choice["delta"]["content"] = redacted_str
if "audio" in choice["delta"]:
choice["delta"]["audio"] = None
elif isinstance(response, str):
standard_logging_object["response"] = redacted_str
else:
# For other formats (empty dict, None, etc.), use simple text format
standard_logging_object["response"] = {"text": redacted_str}
def perform_redaction(model_call_details: dict, result):
"""
Performs the actual redaction on the logging object and result.
@@ -161,29 +114,6 @@ def perform_redaction(model_call_details: dict, result):
if hasattr(_result, "choices") and _result.choices is not None:
for choice in _result.choices:
_redact_choice_content(choice)
elif isinstance(_result, dict) and "choices" in _result:
# Handle dict representation of ModelResponse (e.g., from model_dump())
if _result.get("choices") is not None:
for choice in _result["choices"]:
if isinstance(choice, dict):
if "message" in choice and isinstance(choice["message"], dict):
choice["message"]["content"] = "redacted-by-litellm"
if "reasoning_content" in choice["message"]:
choice["message"]["reasoning_content"] = "redacted-by-litellm"
if "thinking_blocks" in choice["message"]:
choice["message"]["thinking_blocks"] = None
if "audio" in choice["message"]:
choice["message"]["audio"] = None
elif "delta" in choice and isinstance(choice["delta"], dict):
choice["delta"]["content"] = "redacted-by-litellm"
if "reasoning_content" in choice["delta"]:
choice["delta"]["reasoning_content"] = "redacted-by-litellm"
if "thinking_blocks" in choice["delta"]:
choice["delta"]["thinking_blocks"] = None
if "audio" in choice["delta"]:
choice["delta"]["audio"] = None
else:
_redact_choice_content(choice)
elif isinstance(_result, litellm.ResponsesAPIResponse):
if hasattr(_result, "output"):
_redact_responses_api_output(_result.output)
@@ -4,10 +4,7 @@ from typing import List
import litellm
from litellm.exceptions import UnsupportedParamsError
from litellm.llms.openai.chat.gpt_5_transformation import (
OpenAIGPT5Config,
_get_effort_level,
)
from litellm.llms.openai.chat.gpt_5_transformation import OpenAIGPT5Config
from litellm.types.llms.openai import AllMessageValues
from .gpt_transformation import AzureOpenAIConfig
@@ -84,21 +81,20 @@ class AzureOpenAIGPT5Config(AzureOpenAIConfig, OpenAIGPT5Config):
non_default_params.get("reasoning_effort")
or optional_params.get("reasoning_effort")
)
effective_effort = _get_effort_level(reasoning_effort_value)
# gpt-5.1/5.2/5.4 support reasoning_effort='none', but other gpt-5 models don't
# See: https://learn.microsoft.com/en-us/azure/ai-foundry/openai/how-to/reasoning
supports_none = self._supports_reasoning_effort_level(model, "none")
if effective_effort == "none" and not supports_none:
if reasoning_effort_value == "none" and not supports_none:
if litellm.drop_params is True or (
drop_params is not None and drop_params is True
):
non_default_params = non_default_params.copy()
optional_params = optional_params.copy()
if _get_effort_level(non_default_params.get("reasoning_effort")) == "none":
if non_default_params.get("reasoning_effort") == "none":
non_default_params.pop("reasoning_effort")
if _get_effort_level(optional_params.get("reasoning_effort")) == "none":
if optional_params.get("reasoning_effort") == "none":
optional_params.pop("reasoning_effort")
else:
raise UnsupportedParamsError(
@@ -121,19 +117,9 @@ class AzureOpenAIGPT5Config(AzureOpenAIConfig, OpenAIGPT5Config):
)
# Only drop reasoning_effort='none' for models that don't support it
result_effort = _get_effort_level(result.get("reasoning_effort"))
if result_effort == "none" and not supports_none:
if result.get("reasoning_effort") == "none" and not supports_none:
result.pop("reasoning_effort")
# Azure Chat Completions: gpt-5.4+ does not support tools + reasoning together.
# Drop reasoning_effort when both are present (OpenAI routes to Responses API; Azure does not).
if self.is_model_gpt_5_4_plus_model(model):
has_tools = bool(
non_default_params.get("tools") or optional_params.get("tools")
)
if has_tools and result_effort not in (None, "none"):
result.pop("reasoning_effort", None)
return result
def transform_request(
@@ -51,7 +51,6 @@ from litellm.types.llms.openai import (
)
from litellm.types.utils import (
ChatCompletionMessageToolCall,
CompletionTokensDetailsWrapper,
Function,
Message,
ModelResponse,
@@ -64,7 +63,6 @@ from litellm.utils import (
has_tool_call_blocks,
last_assistant_with_tool_calls_has_no_thinking_blocks,
supports_reasoning,
token_counter,
)
from ..common_utils import (
@@ -1208,7 +1206,6 @@ class AmazonConverseConfig(BaseConfig):
self._validate_request_metadata(request_metadata)
output_config: Optional[OutputConfigBlock] = inference_params.pop("outputConfig", None)
inference_params.pop("output_config", None) # Bedrock Converse doesn't support it
# keep supported params in 'inference_params', and set all model-specific params in 'additional_request_params'
additional_request_params = {
@@ -1623,11 +1620,7 @@ class AmazonConverseConfig(BaseConfig):
thinking_blocks_list.append(_redacted_block)
return thinking_blocks_list
def _transform_usage(
self,
usage: ConverseTokenUsageBlock,
reasoning_content: Optional[str] = None,
) -> Usage:
def _transform_usage(self, usage: ConverseTokenUsageBlock) -> Usage:
input_tokens = usage["inputTokens"]
output_tokens = usage["outputTokens"]
total_tokens = usage["totalTokens"]
@@ -1644,19 +1637,6 @@ class AmazonConverseConfig(BaseConfig):
prompt_tokens_details = PromptTokensDetailsWrapper(
cached_tokens=cache_read_input_tokens
)
reasoning_tokens = (
token_counter(text=reasoning_content, count_response_tokens=True)
if reasoning_content
else 0
)
completion_tokens_details = CompletionTokensDetailsWrapper(
reasoning_tokens=reasoning_tokens,
text_tokens=(
output_tokens - reasoning_tokens
if reasoning_tokens > 0
else output_tokens
),
)
openai_usage = Usage(
prompt_tokens=input_tokens,
completion_tokens=output_tokens,
@@ -1664,7 +1644,6 @@ class AmazonConverseConfig(BaseConfig):
prompt_tokens_details=prompt_tokens_details,
cache_creation_input_tokens=cache_creation_input_tokens,
cache_read_input_tokens=cache_read_input_tokens,
completion_tokens_details=completion_tokens_details,
)
return openai_usage
@@ -2001,10 +1980,7 @@ class AmazonConverseConfig(BaseConfig):
chat_completion_message["tool_calls"] = filtered_tools
## CALCULATING USAGE - bedrock returns usage in the headers
usage = self._transform_usage(
completion_response["usage"],
reasoning_content=chat_completion_message.get("reasoning_content"),
)
usage = self._transform_usage(completion_response["usage"])
## HANDLE TOOL CALLS
_message = Message(**chat_completion_message)
@@ -426,11 +426,8 @@ class FireworksAIConfig(OpenAIGPTConfig):
"FIREWORKS_ACCOUNT_ID is not set. Please set the environment variable, to query Fireworks AI's `/models` endpoint."
)
base = api_base.rstrip("/")
if base.endswith("/v1"):
base = base[: -len("/v1")]
response = litellm.module_level_client.get(
url=f"{base}/v1/accounts/{account_id}/models",
url=f"{api_base}/v1/accounts/{account_id}/models",
headers={"Authorization": f"Bearer {api_key}"},
)
@@ -25,22 +25,6 @@ def _normalize_reasoning_effort_for_chat_completion(
return None
def _get_effort_level(value: Union[str, dict, None]) -> Optional[str]:
"""Extract the effective effort level from reasoning_effort (string or dict).
Use this for guards that compare effort level (e.g. xhigh validation, "none" checks).
Ensures dict inputs like {"effort": "none", "summary": "detailed"} are correctly
treated as effort="none" for validation purposes.
"""
if value is None:
return None
if isinstance(value, str):
return value
if isinstance(value, dict) and "effort" in value:
return value["effort"]
return None
class OpenAIGPT5Config(OpenAIGPTConfig):
"""Configuration for gpt-5 models including GPT-5-Codex variants.
@@ -86,19 +70,6 @@ class OpenAIGPT5Config(OpenAIGPTConfig):
model_name = model.split("/")[-1]
return model_name.startswith("gpt-5.4")
@classmethod
def is_model_gpt_5_4_plus_model(cls, model: str) -> bool:
"""Check if the model is gpt-5.4 or newer (5.4, 5.5, 5.6, etc., including pro)."""
model_name = model.split("/")[-1]
if not model_name.startswith("gpt-5."):
return False
try:
version_str = model_name.replace("gpt-5.", "").split("-")[0]
major = version_str.split(".")[0]
return int(major) >= 4
except (ValueError, IndexError):
return False
@classmethod
def _supports_reasoning_effort_level(cls, model: str, level: str) -> bool:
"""Check if the model supports a specific reasoning_effort level.
@@ -179,32 +150,21 @@ class OpenAIGPT5Config(OpenAIGPTConfig):
drop_params=drop_params,
)
# Get raw reasoning_effort and effective effort level for all guards.
# Use effective_effort (extracted string) for xhigh validation, "none" checks, and
# tool/sampling guards — dict inputs like {"effort": "none", "summary": "detailed"}
# must be treated as effort="none" to avoid incorrect tool-drop or sampling errors.
# Normalize reasoning_effort: chat completion API expects a string, not a dict
# (e.g. {'effort': 'high', 'summary': 'detailed'} -> 'high')
raw_reasoning_effort = (
non_default_params.get("reasoning_effort")
or optional_params.get("reasoning_effort")
)
effective_effort = _get_effort_level(raw_reasoning_effort)
normalized = _normalize_reasoning_effort_for_chat_completion(raw_reasoning_effort)
if raw_reasoning_effort is not None and normalized is not None:
if "reasoning_effort" in non_default_params:
non_default_params["reasoning_effort"] = normalized
if "reasoning_effort" in optional_params:
optional_params["reasoning_effort"] = normalized
# Normalize to string for Chat Completions API when dict has only "effort".
# Preserve full dict (e.g. {"effort": "high", "summary": "detailed"}) for Responses API.
if isinstance(raw_reasoning_effort, dict) and set(raw_reasoning_effort.keys()) <= {"effort"}:
normalized = _normalize_reasoning_effort_for_chat_completion(raw_reasoning_effort)
if normalized is not None:
if "reasoning_effort" in non_default_params:
non_default_params["reasoning_effort"] = normalized
if "reasoning_effort" in optional_params:
optional_params["reasoning_effort"] = normalized
reasoning_effort = (
non_default_params.get("reasoning_effort")
or optional_params.get("reasoning_effort")
or raw_reasoning_effort
)
if effective_effort is not None and effective_effort == "xhigh":
reasoning_effort = normalized or raw_reasoning_effort
if reasoning_effort is not None and reasoning_effort == "xhigh":
if not self._supports_reasoning_effort_level(model, "xhigh"):
if litellm.drop_params or drop_params:
non_default_params.pop("reasoning_effort", None)
@@ -231,20 +191,17 @@ class OpenAIGPT5Config(OpenAIGPTConfig):
has_tools = bool(
non_default_params.get("tools") or optional_params.get("tools")
)
if has_tools and effective_effort not in (None, "none"):
# Check if this will be routed to Responses API
# If so, keep reasoning_effort; otherwise drop it for chat completions API
if not self.is_model_gpt_5_4_plus_model(model):
non_default_params.pop("reasoning_effort", None)
optional_params.pop("reasoning_effort", None)
reasoning_effort = None
if has_tools and reasoning_effort not in (None, "none"):
non_default_params.pop("reasoning_effort", None)
optional_params.pop("reasoning_effort", None)
reasoning_effort = None
# gpt-5.1/5.2 support logprobs, top_p, top_logprobs only when reasoning_effort="none"
supports_none = self._supports_reasoning_effort_level(model, "none")
if supports_none:
sampling_params = ["logprobs", "top_logprobs", "top_p"]
has_sampling = any(p in non_default_params for p in sampling_params)
if has_sampling and effective_effort not in (None, "none"):
if has_sampling and reasoning_effort not in (None, "none"):
if litellm.drop_params or drop_params:
for p in sampling_params:
non_default_params.pop(p, None)
@@ -254,7 +211,7 @@ class OpenAIGPT5Config(OpenAIGPTConfig):
"gpt-5.1/5.2/5.4 only support logprobs, top_p, top_logprobs when "
"reasoning_effort='none'. Current reasoning_effort='{}'. "
"To drop unsupported params set `litellm.drop_params = True`"
).format(effective_effort),
).format(reasoning_effort),
status_code=400,
)
@@ -262,7 +219,7 @@ class OpenAIGPT5Config(OpenAIGPTConfig):
temperature_value: Optional[float] = non_default_params.pop("temperature")
if temperature_value is not None:
# models supporting reasoning_effort="none" also support flexible temperature
if supports_none and (effective_effort == "none" or effective_effort is None):
if supports_none and (reasoning_effort == "none" or reasoning_effort is None):
optional_params["temperature"] = temperature_value
elif temperature_value == 1:
optional_params["temperature"] = temperature_value
@@ -40,6 +40,7 @@ class OpenAIImageEditConfig(BaseImageEditConfig):
"image",
"prompt",
"background",
"input_fidelity",
"mask",
"model",
"n",
+35 -3
View File
@@ -10,8 +10,9 @@ Instead of creating a full Python module for simple OpenAI-compatible providers,
- `providers.json` - Configuration file for all JSON-based providers
- `json_loader.py` - Loads and parses the JSON configuration
- `dynamic_config.py` - Generates Python config classes from JSON
- `chat/` - Existing OpenAI-like chat completion handlers
- `dynamic_config.py` - Generates Python config classes from JSON (chat + responses)
- `chat/` - OpenAI-like chat completion handlers
- `responses/` - OpenAI-like Responses API handlers
## Adding a New Provider
@@ -96,6 +97,32 @@ response = litellm.completion(
)
```
## Responses API Support
Providers that support the OpenAI Responses API (`/v1/responses`) can declare it via `supported_endpoints`:
```json
{
"your_provider": {
"base_url": "https://api.yourprovider.com/v1",
"api_key_env": "YOUR_PROVIDER_API_KEY",
"supported_endpoints": ["/v1/chat/completions", "/v1/responses"]
}
}
```
This enables `litellm.responses(model="your_provider/model-name", ...)` with zero Python code.
The provider inherits all request/response handling from OpenAI's Responses API config.
If `supported_endpoints` is omitted, it defaults to `[]` (only chat completions, which is always enabled for JSON providers).
### How It Works
1. `json_loader.py` checks `supported_endpoints` for `/v1/responses`
2. `dynamic_config.py` generates a responses config class (inherits from `OpenAIResponsesAPIConfig`)
3. `ProviderConfigManager.get_provider_responses_api_config()` returns the generated config
4. Request/response transformation is inherited from OpenAI — no custom code needed
## Benefits
- **Simple**: 2-5 lines of JSON vs 100+ lines of Python
@@ -112,6 +139,10 @@ Use a Python config class if you need:
- Provider-specific streaming logic
- Advanced tool calling transformations
For providers that are *mostly* OpenAI-compatible but need small overrides (e.g. preset model handling),
you can inherit from `OpenAIResponsesAPIConfig` and override only what's needed — see
`litellm/llms/perplexity/responses/transformation.py` for a minimal example (~40 lines).
## Implementation Details
### How It Works
@@ -125,5 +156,6 @@ Use a Python config class if you need:
The JSON system is integrated at:
- `litellm/litellm_core_utils/get_llm_provider_logic.py` - Provider resolution
- `litellm/utils.py` - ProviderConfigManager
- `litellm/utils.py` - ProviderConfigManager (chat + responses)
- `litellm/responses/main.py` - Responses API routing
- `litellm/constants.py` - openai_compatible_providers list
@@ -166,3 +166,63 @@ def create_config_class(provider: SimpleProviderConfig):
return provider.slug
return JSONProviderConfig
_responses_config_cache: dict = {}
def create_responses_config_class(provider: SimpleProviderConfig):
"""Generate a Responses API config class dynamically from JSON configuration.
Parallel to create_config_class() but for /v1/responses endpoints.
Classes are cached per provider slug to avoid regeneration on every request.
"""
if provider.slug in _responses_config_cache:
return _responses_config_cache[provider.slug]
from litellm.llms.openai_like.responses.transformation import (
OpenAILikeResponsesConfig,
)
from litellm.types.router import GenericLiteLLMParams
class JSONProviderResponsesConfig(OpenAILikeResponsesConfig):
@property
def custom_llm_provider(self): # type: ignore[override]
return provider.slug
def validate_environment(
self,
headers: dict,
model: str,
litellm_params: Optional[GenericLiteLLMParams],
) -> dict:
litellm_params = litellm_params or GenericLiteLLMParams()
api_key = (
litellm_params.api_key
or get_secret_str(provider.api_key_env)
)
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
return headers
def get_complete_url(
self,
api_base: Optional[str],
litellm_params: dict,
) -> str:
if not api_base:
if provider.api_base_env:
api_base = get_secret_str(provider.api_base_env)
if not api_base:
api_base = provider.base_url
if api_base is None:
raise ValueError(
f"api_base is required for provider {provider.slug}"
)
api_base = api_base.rstrip("/")
return f"{api_base}/responses"
_responses_config_cache[provider.slug] = JSONProviderResponsesConfig
return JSONProviderResponsesConfig
+9
View File
@@ -21,6 +21,7 @@ class SimpleProviderConfig:
self.param_mappings = data.get("param_mappings", {})
self.constraints = data.get("constraints", {})
self.special_handling = data.get("special_handling", {})
self.supported_endpoints = data.get("supported_endpoints", [])
class JSONProviderRegistry:
@@ -64,6 +65,14 @@ class JSONProviderRegistry:
"""Check if a provider is defined via JSON"""
return slug in cls._providers
@classmethod
def supports_responses_api(cls, slug: str) -> bool:
"""Check if a JSON provider supports the Responses API"""
provider = cls._providers.get(slug)
if provider is None:
return False
return "/v1/responses" in provider.supported_endpoints
@classmethod
def list_providers(cls) -> list:
"""List all registered provider slugs"""
@@ -0,0 +1,5 @@
from litellm.llms.openai_like.responses.transformation import (
OpenAILikeResponsesConfig,
)
__all__ = ["OpenAILikeResponsesConfig"]
@@ -0,0 +1,51 @@
"""
OpenAI-like Responses API transformation.
Base class for JSON-declared providers that support the /v1/responses endpoint.
Inherits everything from OpenAIResponsesAPIConfig; subclasses only override
provider-specific resolution (slug, API key env var, base URL).
"""
from typing import Optional, Union
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
from litellm.secret_managers.main import get_secret_str
from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import LlmProviders
class OpenAILikeResponsesConfig(OpenAIResponsesAPIConfig):
"""
Responses API config for OpenAI-compatible providers declared via JSON.
Concrete per-provider classes are generated dynamically in dynamic_config.py.
This base provides the three overridable hooks that the dynamic generator
fills in: custom_llm_provider, validate_environment, get_complete_url.
"""
@property
def custom_llm_provider(self) -> Union[str, LlmProviders]: # type: ignore[override]
return "openai_like"
def validate_environment(
self,
headers: dict,
model: str,
litellm_params: Optional[GenericLiteLLMParams],
) -> dict:
litellm_params = litellm_params or GenericLiteLLMParams()
api_key = litellm_params.api_key or get_secret_str("OPENAI_LIKE_API_KEY")
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
return headers
def get_complete_url(
self,
api_base: Optional[str],
litellm_params: dict,
) -> str:
api_base = api_base or get_secret_str("OPENAI_LIKE_API_BASE")
if not api_base:
raise ValueError("api_base is required for openai_like provider")
api_base = api_base.rstrip("/")
return f"{api_base}/responses"
@@ -1,54 +1,31 @@
"""
Transformation logic for Perplexity Agent API (Responses API)
Perplexity Responses API OpenAI-compatible.
This module handles the translation between OpenAI's Responses API format
and Perplexity's Responses API format, which supports:
- Third-party model access (OpenAI, Anthropic, Google, xAI, etc.)
- Presets for optimized configurations
- Web search and URL fetching tools
- Reasoning effort control
- Instructions parameter for system-level guidance
The only provider quirks:
- cost returned as dict handled by ResponseAPIUsage.parse_cost validator
- preset models (preset/pro-search) handled by transform_responses_api_request
- HTTP 200 with status:"failed" raised as exception in transform_response_api_response
Ref: https://docs.perplexity.ai/api-reference/responses-post
"""
from typing import Any, Dict, List, Optional, Union
import httpx
from litellm._logging import verbose_logger
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.openai import (
ResponseAPIUsage,
ResponseInputParam,
ResponsesAPIOptionalRequestParams,
ResponsesAPIResponse,
ResponsesAPIStreamingResponse,
)
from litellm.types.llms.openai import ResponseInputParam, ResponsesAPIResponse
from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import LlmProviders
class PerplexityResponsesConfig(OpenAIResponsesAPIConfig):
"""
Configuration for Perplexity Agent API (Responses API)
Reference: https://docs.perplexity.ai/docs/agent-api/overview
"""
@property
def custom_llm_provider(self) -> LlmProviders:
return LlmProviders.PERPLEXITY
def get_supported_openai_params(self, model: str) -> list:
"""
Perplexity Responses API supports a different set of parameters
Ref: https://docs.perplexity.ai/api-reference/responses-post
Params aligned with response-echo fields and Open Responses spec.
"""
"""Ref: https://docs.perplexity.ai/api-reference/responses-post"""
return [
"max_output_tokens",
"stream",
@@ -56,200 +33,45 @@ class PerplexityResponsesConfig(OpenAIResponsesAPIConfig):
"top_p",
"tools",
"reasoning",
"preset",
"instructions",
"models", # Model fallback support
"tool_choice",
"parallel_tool_calls",
"max_tool_calls",
"text",
"previous_response_id",
"store",
"background",
"truncation",
"metadata",
"safety_identifier",
"user",
"stream_options",
"top_logprobs",
"prompt_cache_key",
"frequency_penalty",
"presence_penalty",
"service_tier",
"models",
]
@property
def custom_llm_provider(self) -> LlmProviders:
return LlmProviders.PERPLEXITY
def validate_environment(
self, headers: dict, model: str, litellm_params: Optional[GenericLiteLLMParams]
) -> dict:
"""Validate environment and set up headers"""
# Get API key from environment
api_key = get_secret_str("PERPLEXITYAI_API_KEY") or get_secret_str(
"PERPLEXITY_API_KEY"
litellm_params = litellm_params or GenericLiteLLMParams()
api_key = (
litellm_params.api_key
or get_secret_str("PERPLEXITYAI_API_KEY")
or get_secret_str("PERPLEXITY_API_KEY")
)
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
headers["Content-Type"] = "application/json"
return headers
def get_complete_url(
self,
api_base: Optional[str],
litellm_params: dict,
) -> str:
"""Get the complete URL for the Perplexity Responses API"""
if api_base is None:
api_base = (
get_secret_str("PERPLEXITY_API_BASE") or "https://api.perplexity.ai"
)
def get_complete_url(self, api_base: Optional[str], litellm_params: dict) -> str:
api_base = api_base or get_secret_str("PERPLEXITY_API_BASE") or "https://api.perplexity.ai"
return f"{api_base.rstrip('/')}/v1/responses"
# Ensure api_base doesn't end with a slash
api_base = api_base.rstrip("/")
# Add the responses endpoint
return f"{api_base}/v1/responses"
def map_openai_params( # noqa: PLR0915
self,
response_api_optional_params: ResponsesAPIOptionalRequestParams,
model: str,
drop_params: bool,
) -> Dict:
"""
Map OpenAI Responses API parameters to Perplexity format
Key differences:
- Supports 'preset' parameter for predefined configurations
- Supports 'instructions' parameter for system-level guidance
- Tools are specified differently (web_search, fetch_url)
"""
mapped_params: Dict[str, Any] = {}
# Map standard parameters
if response_api_optional_params.get("max_output_tokens"):
mapped_params["max_output_tokens"] = response_api_optional_params[
"max_output_tokens"
]
if response_api_optional_params.get("temperature"):
mapped_params["temperature"] = response_api_optional_params["temperature"]
if response_api_optional_params.get("top_p"):
mapped_params["top_p"] = response_api_optional_params["top_p"]
if response_api_optional_params.get("stream"):
mapped_params["stream"] = response_api_optional_params["stream"]
if response_api_optional_params.get("stream_options"):
mapped_params["stream_options"] = response_api_optional_params[
"stream_options"
]
# Map Perplexity-specific parameters (using .get() with Any dict access)
preset = response_api_optional_params.get("preset") # type: ignore
if preset:
mapped_params["preset"] = preset
instructions = response_api_optional_params.get("instructions") # type: ignore
if instructions:
mapped_params["instructions"] = instructions
if response_api_optional_params.get("reasoning"):
mapped_params["reasoning"] = response_api_optional_params["reasoning"]
tools = response_api_optional_params.get("tools")
if tools:
# Convert tools to list of dicts for transformation
tools_list = [dict(tool) if hasattr(tool, "__dict__") else tool for tool in tools] # type: ignore
mapped_params["tools"] = self._transform_tools(tools_list) # type: ignore
# Tool control
if response_api_optional_params.get("tool_choice"):
mapped_params["tool_choice"] = response_api_optional_params["tool_choice"]
if response_api_optional_params.get("parallel_tool_calls") is not None:
mapped_params["parallel_tool_calls"] = response_api_optional_params[
"parallel_tool_calls"
]
if response_api_optional_params.get("max_tool_calls"):
mapped_params["max_tool_calls"] = response_api_optional_params[
"max_tool_calls"
]
# Structured outputs
text_param = response_api_optional_params.get("text")
if text_param:
mapped_params["text"] = text_param
# Conversation continuity
if response_api_optional_params.get("previous_response_id"):
mapped_params["previous_response_id"] = response_api_optional_params[
"previous_response_id"
]
# Storage and lifecycle
if response_api_optional_params.get("store") is not None:
mapped_params["store"] = response_api_optional_params["store"]
if response_api_optional_params.get("background") is not None:
mapped_params["background"] = response_api_optional_params["background"]
if response_api_optional_params.get("truncation"):
mapped_params["truncation"] = response_api_optional_params["truncation"]
# Metadata
if response_api_optional_params.get("metadata"):
mapped_params["metadata"] = response_api_optional_params["metadata"]
if response_api_optional_params.get("safety_identifier"):
mapped_params["safety_identifier"] = response_api_optional_params[
"safety_identifier"
]
if response_api_optional_params.get("user"):
mapped_params["user"] = response_api_optional_params["user"]
# Additional
if response_api_optional_params.get("top_logprobs") is not None:
mapped_params["top_logprobs"] = response_api_optional_params["top_logprobs"]
if response_api_optional_params.get("prompt_cache_key"):
mapped_params["prompt_cache_key"] = response_api_optional_params[
"prompt_cache_key"
]
if response_api_optional_params.get("frequency_penalty") is not None:
mapped_params["frequency_penalty"] = response_api_optional_params[
"frequency_penalty" # type: ignore[typeddict-item]
]
if response_api_optional_params.get("presence_penalty") is not None:
mapped_params["presence_penalty"] = response_api_optional_params[
"presence_penalty" # type: ignore[typeddict-item]
]
if response_api_optional_params.get("service_tier"):
mapped_params["service_tier"] = response_api_optional_params["service_tier"]
return mapped_params
def _transform_tools(self, tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
Transform tools to Perplexity format.
Perplexity supports (per public OpenAPI spec):
- web_search: Performs web searches
- fetch_url: Fetches content from URLs
- function: Function Calling
"""
perplexity_tools = []
for tool in tools:
if isinstance(tool, dict):
tool_type = tool.get("type", "")
# Direct Perplexity tool format
if tool_type in ["web_search", "fetch_url"]:
perplexity_tools.append(tool)
# Function tools: Perplexity supports them natively
elif tool_type == "function":
perplexity_tools.append(tool)
return perplexity_tools
def _ensure_message_type(
self, input: Union[str, ResponseInputParam]
) -> Union[str, List[Dict[str, Any]]]:
"""Ensure list input items have type='message' (required by Perplexity)."""
if isinstance(input, str):
return input
if isinstance(input, list):
result = []
for item in input:
if isinstance(item, dict) and "type" not in item:
item = {**item, "type": "message"}
result.append(item)
return result
return input
def transform_responses_api_request(
self,
@@ -259,62 +81,23 @@ class PerplexityResponsesConfig(OpenAIResponsesAPIConfig):
litellm_params: GenericLiteLLMParams,
headers: dict,
) -> Dict:
"""
Transform request to Perplexity Responses API format
"""
# Check if the model is a preset (format: preset/preset-name)
"""Handle preset/ model prefix: send as {"preset": name} instead of {"model": name}."""
input = self._ensure_message_type(input)
if model.startswith("preset/"):
preset_name = model.replace("preset/", "")
data = {
"preset": preset_name,
"input": self._format_input(input),
input = self._validate_input_param(input)
data: Dict = {
"preset": model[len("preset/"):],
"input": input,
}
# Check if preset is explicitly provided in params
elif response_api_optional_request_params.get("preset"):
data = {
"preset": response_api_optional_request_params.pop("preset"),
"input": self._format_input(input),
}
else:
# Full request format for third-party models
data = {
"model": model,
"input": self._format_input(input),
}
# Add all optional parameters
for key, value in response_api_optional_request_params.items():
data[key] = value
return data
def _format_input(
self, input: Union[str, ResponseInputParam]
) -> Union[str, List[Dict[str, Any]]]:
"""
Format input for Perplexity Responses API
The API accepts either:
- A simple string for single-turn queries
- An array of message objects for multi-turn conversations
"""
if isinstance(input, str):
return input
# Handle ResponseInputParam format
if isinstance(input, list):
formatted_messages = []
for item in input:
if isinstance(item, dict):
formatted_message = {
"type": "message",
"role": item.get("role"),
"content": item.get("content", ""),
}
formatted_messages.append(formatted_message)
return formatted_messages
return str(input)
data.update(response_api_optional_request_params)
return data
return super().transform_responses_api_request(
model=model,
input=input,
response_api_optional_request_params=response_api_optional_request_params,
litellm_params=litellm_params,
headers=headers,
)
def transform_response_api_response(
self,
@@ -322,174 +105,27 @@ class PerplexityResponsesConfig(OpenAIResponsesAPIConfig):
raw_response: httpx.Response,
logging_obj: LiteLLMLoggingObj,
) -> ResponsesAPIResponse:
"""
Transform Perplexity Responses API response to OpenAI Responses API format
"""
"""Check for Perplexity's status:'failed' on HTTP 200 before delegating to base."""
try:
raw_response_json = raw_response.json()
except Exception as e:
raise BaseLLMException(
status_code=raw_response.status_code,
message=f"Failed to parse response: {str(e)}",
)
# Check for error status
status = raw_response_json.get("status")
if status == "failed":
error = raw_response_json.get("error", {})
error_message = error.get("message", "Unknown error")
raise BaseLLMException(
status_code=raw_response.status_code,
message=error_message,
)
# Transform usage to handle Perplexity's cost structure
usage_data = raw_response_json.get("usage", {})
transformed_usage_dict = self._transform_usage(usage_data)
# Convert usage dict to ResponseAPIUsage object
usage_obj = (
ResponseAPIUsage(**transformed_usage_dict)
if transformed_usage_dict
else None
)
# Map Perplexity response to OpenAI Responses API format
response = ResponsesAPIResponse(
id=raw_response_json.get("id", ""),
object="response",
created_at=raw_response_json.get("created_at", 0),
status=raw_response_json.get("status", "completed"),
model=raw_response_json.get("model", model),
output=raw_response_json.get("output", []),
usage=usage_obj,
)
return response
def _transform_usage(self, usage_data: Dict[str, Any]) -> Dict[str, Any]:
"""
Transform Perplexity usage data to OpenAI format
Perplexity returns:
{
"input_tokens": 100,
"output_tokens": 200,
"total_tokens": 300,
"cost": {
"currency": "USD",
"input_cost": 0.0001,
"output_cost": 0.0002,
"total_cost": 0.0003
}
}
OpenAI expects:
{
"input_tokens": 100,
"output_tokens": 200,
"total_tokens": 300,
"cost": 0.0003
}
"""
transformed = {
"input_tokens": usage_data.get("input_tokens", 0),
"output_tokens": usage_data.get("output_tokens", 0),
"total_tokens": usage_data.get("total_tokens", 0),
}
# Transform cost from Perplexity format (dict) to OpenAI format (float)
cost_obj = usage_data.get("cost")
if isinstance(cost_obj, dict) and "total_cost" in cost_obj:
transformed["cost"] = cost_obj["total_cost"]
verbose_logger.debug(
"Transformed Perplexity cost object to float: %s -> %s",
cost_obj,
cost_obj["total_cost"],
)
elif cost_obj is not None:
# If cost is already a float/number, use it as-is
transformed["cost"] = cost_obj
# Add input_tokens_details if present
if "input_tokens_details" in usage_data:
transformed["input_tokens_details"] = usage_data["input_tokens_details"]
# Add output_tokens_details if present
if "output_tokens_details" in usage_data:
transformed["output_tokens_details"] = usage_data["output_tokens_details"]
return transformed
def transform_streaming_response(
self,
model: str,
parsed_chunk: dict,
logging_obj: LiteLLMLoggingObj,
) -> ResponsesAPIStreamingResponse:
"""
Transform a parsed streaming response chunk into a ResponsesAPIStreamingResponse
"""
# Get the event type from the chunk
verbose_logger.debug("Raw Perplexity Chunk=%s", parsed_chunk)
event_type = str(parsed_chunk.get("type"))
event_pydantic_model = PerplexityResponsesConfig.get_event_model_class(
event_type=event_type
)
# Transform Perplexity-specific fields to OpenAI format
parsed_chunk = self._transform_perplexity_chunk(parsed_chunk)
# Defensive: Handle error.code being null (similar to OpenAI implementation)
try:
error_obj = parsed_chunk.get("error")
if isinstance(error_obj, dict) and error_obj.get("code") is None:
# Preserve other fields, but ensure `code` is a non-null string
parsed_chunk = dict(parsed_chunk)
parsed_chunk["error"] = dict(error_obj)
parsed_chunk["error"]["code"] = "unknown_error"
except Exception:
# If anything unexpected happens here, fall back to attempting
# instantiation and let higher-level handlers manage errors.
verbose_logger.debug("Failed to coalesce error.code in parsed_chunk")
raw_response_json = None
return event_pydantic_model(**parsed_chunk)
if (
isinstance(raw_response_json, dict)
and raw_response_json.get("status") == "failed"
):
error = raw_response_json.get("error", {})
raise BaseLLMException(
status_code=raw_response.status_code,
message=error.get("message", "Unknown Perplexity error"),
)
def _transform_perplexity_chunk(self, chunk: dict) -> dict:
"""
Transform Perplexity-specific fields in a streaming chunk to OpenAI format.
This handles:
- Converting Perplexity's cost object to a simple float
"""
# Make a copy to avoid modifying the original
chunk = dict(chunk)
# Transform usage.cost from Perplexity format to OpenAI format
# Perplexity: {"currency": "USD", "input_cost": 0.0001, "output_cost": 0.0002, "total_cost": 0.0003}
# OpenAI: 0.0003 (just the total_cost as a float)
try:
response_obj = chunk.get("response")
if isinstance(response_obj, dict):
usage_obj = response_obj.get("usage")
if isinstance(usage_obj, dict):
cost_obj = usage_obj.get("cost")
if isinstance(cost_obj, dict) and "total_cost" in cost_obj:
# Replace the cost object with just the total_cost value
chunk = dict(chunk)
chunk["response"] = dict(response_obj)
chunk["response"]["usage"] = dict(usage_obj)
chunk["response"]["usage"]["cost"] = cost_obj["total_cost"]
verbose_logger.debug(
"Transformed Perplexity cost object to float: %s -> %s",
cost_obj,
cost_obj["total_cost"],
)
except Exception as e:
# If transformation fails, log and continue with original chunk
verbose_logger.debug("Failed to transform Perplexity cost object: %s", e)
return chunk
return super().transform_response_api_response(
model=model,
raw_response=raw_response,
logging_obj=logging_obj,
)
def supports_native_websocket(self) -> bool:
"""Perplexity does not support native WebSocket for Responses API"""
+36 -20
View File
@@ -583,17 +583,35 @@ class SagemakerLLM(BaseAWSLLM):
### BOTO3 INIT
import boto3
# Use _load_credentials to support role assumption (aws_role_name, aws_session_name)
credentials, aws_region_name = self._load_credentials(optional_params)
# pop aws_secret_access_key, aws_access_key_id, aws_region_name from kwargs, since completion calls fail with them
aws_secret_access_key = optional_params.pop("aws_secret_access_key", None)
aws_access_key_id = optional_params.pop("aws_access_key_id", None)
aws_region_name = optional_params.pop("aws_region_name", None)
# Create boto3 session with the loaded credentials
session = boto3.Session(
aws_access_key_id=credentials.access_key,
aws_secret_access_key=credentials.secret_key,
aws_session_token=credentials.token,
region_name=aws_region_name,
)
client = session.client(service_name="sagemaker-runtime")
if aws_access_key_id is not None:
# uses auth params passed to completion
# aws_access_key_id is not None, assume user is trying to auth using litellm.completion
client = boto3.client(
service_name="sagemaker-runtime",
aws_access_key_id=aws_access_key_id,
aws_secret_access_key=aws_secret_access_key,
region_name=aws_region_name,
)
else:
# aws_access_key_id is None, assume user is trying to auth using env variables
# boto3 automaticaly reads env variables
# we need to read region name from env
# I assume majority of users use .env for auth
region_name = (
get_secret("AWS_REGION_NAME")
or aws_region_name # get region from config file if specified
or "us-west-2" # default to us-west-2 if region not specified
)
client = boto3.client(
service_name="sagemaker-runtime",
region_name=region_name,
)
# pop streaming if it's in the optional params as 'stream' raises an error with sagemaker
inference_params = deepcopy(optional_params)
@@ -610,9 +628,7 @@ class SagemakerLLM(BaseAWSLLM):
#### EMBEDDING LOGIC
# Transform request based on model type
provider_config = SagemakerEmbeddingConfig.get_model_config(model)
request_data = provider_config.transform_embedding_request(
model, input, optional_params, {}
)
request_data = provider_config.transform_embedding_request(model, input, optional_params, {})
data = json.dumps(request_data).encode("utf-8")
## LOGGING
@@ -657,19 +673,19 @@ class SagemakerLLM(BaseAWSLLM):
)
print_verbose(f"raw model_response: {response}")
# Transform response based on model type
from httpx import Response as HttpxResponse
# Create a mock httpx Response object for the transformation
mock_response = HttpxResponse(
status_code=200,
content=json.dumps(response).encode("utf-8"),
headers={"content-type": "application/json"},
content=json.dumps(response).encode('utf-8'),
headers={"content-type": "application/json"}
)
model_response = EmbeddingResponse()
# Use the request_data that was already transformed above
return provider_config.transform_embedding_response(
model=model,
@@ -679,5 +695,5 @@ class SagemakerLLM(BaseAWSLLM):
api_key=None,
request_data=request_data,
optional_params=optional_params,
litellm_params=litellm_params or {},
litellm_params=litellm_params or {}
)
+11 -10
View File
@@ -208,27 +208,28 @@ class SnowflakeConfig(SnowflakeBaseConfig, OpenAIGPTConfig):
def _transform_tool_choice(
self, tool_choice: Union[str, Dict[str, Any]]
) -> Union[str, Dict[str, Any]]:
) -> Dict[str, Any]:
"""
Transform OpenAI tool_choice format to Snowflake format.
Snowflake requires tool_choice to be an object, not a string.
Ref: https://docs.snowflake.com/en/developer-guide/snowflake-rest-api/reference/cortex-inference#post--api-v2-cortex-inference-complete-req-body-schema
Args:
tool_choice: Tool choice in OpenAI format (str or dict)
Returns:
Tool choice in Snowflake format
Tool choice in Snowflake format (always an object)
OpenAI format:
{"type": "function", "function": {"name": "get_weather"}}
OpenAI format (string): "auto", "required", "none"
OpenAI format (object): {"type": "function", "function": {"name": "get_weather"}}
Snowflake format:
{"type": "tool", "name": ["get_weather"]}
Note: String values ("auto", "required", "none") pass through unchanged.
Snowflake format (string values become objects): {"type": "auto"}
Snowflake format (specific tool): {"type": "tool", "name": ["get_weather"]}
"""
if isinstance(tool_choice, str):
# "auto", "required", "none" pass through as-is
return tool_choice
# Snowflake requires object format: {"type": "auto"} not string "auto"
return {"type": tool_choice}
if isinstance(tool_choice, dict):
if tool_choice.get("type") == "function":
+23
View File
@@ -516,6 +516,29 @@ def _build_vertex_schema(parameters: dict, add_property_ordering: bool = False):
return parameters
def _build_vertex_schema_for_gemini_2(parameters: dict) -> dict:
"""
Minimal schema builder for Gemini 2.0+ tool parameters.
Gemini 2.0+ accepts standard JSON Schema natively in tool parameters,
including lowercase types, anyOf with null, and bare {} (TYPE_UNSPECIFIED).
The only transformation needed is resolving $ref/$defs, which Gemini does
NOT support in tool parameters (returns 400).
This avoids the harmful transforms in _build_vertex_schema that break
JsonValue/Any semantics by coercing {} to {"type": "object"}.
"""
valid_schema_fields = set(get_type_hints(Schema).keys())
parameters = dict(parameters) # shallow copy to avoid mutating caller's dict
defs = parameters.pop("$defs", {})
unpack_defs(parameters, defs)
parameters = filter_schema_fields(parameters, valid_schema_fields)
return parameters
def _build_json_schema(parameters: dict) -> dict:
"""
Build a JSON Schema for use with Gemini's responseJsonSchema parameter.
@@ -583,18 +583,12 @@ def _gemini_convert_messages_with_history( # noqa: PLR0915
raise e
# Keys that LiteLLM consumes internally and must never be forwarded to the
_LITELLM_INTERNAL_EXTRA_BODY_KEYS: frozenset = frozenset({"cache", "tags"})
def _pop_and_merge_extra_body(data: RequestBody, optional_params: dict) -> None:
"""Pop extra_body from optional_params and shallow-merge into data, deep-merging dict values."""
extra_body: Optional[dict] = optional_params.pop("extra_body", None)
if extra_body is not None:
data_dict: dict = data # type: ignore[assignment]
for k, v in extra_body.items():
if k in _LITELLM_INTERNAL_EXTRA_BODY_KEYS:
continue
if k in data_dict and isinstance(data_dict[k], dict) and isinstance(v, dict):
data_dict[k].update(v)
else:
@@ -97,6 +97,7 @@ from ..common_utils import (
VertexAIError,
_build_json_schema,
_build_vertex_schema,
_build_vertex_schema_for_gemini_2,
supports_response_json_schema,
)
from ..vertex_llm_base import VertexBase
@@ -467,7 +468,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
return None
def _map_function( # noqa: PLR0915
self, value: List[dict], optional_params: dict
self, value: List[dict], optional_params: dict, model: str = ""
) -> List[Tools]:
"""
Map OpenAI-style tools/functions to Vertex AI format.
@@ -510,10 +511,21 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
"parameters" in _openai_function_object
and _openai_function_object["parameters"] is not None
and isinstance(_openai_function_object["parameters"], dict)
): # OPENAI accepts JSON Schema, Google accepts OpenAPI schema.
_openai_function_object["parameters"] = _build_vertex_schema(
_openai_function_object["parameters"]
)
):
if supports_response_json_schema(model):
# Gemini 2.0+: minimal transform (resolve $ref only)
_openai_function_object["parameters"] = (
_build_vertex_schema_for_gemini_2(
_openai_function_object["parameters"]
)
)
else:
# Gemini 1.5: full OpenAPI-style transform
_openai_function_object["parameters"] = (
_build_vertex_schema(
_openai_function_object["parameters"]
)
)
openai_function_object = _openai_function_object
@@ -1051,7 +1063,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
):
# Pass optional_params so _map_function can add toolConfig if needed
mapped_tools = self._map_function(
value=value, optional_params=optional_params
value=value, optional_params=optional_params, model=model
)
optional_params = self._add_tools_to_optional_params(
optional_params, mapped_tools
@@ -1230,27 +1242,25 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
"IMAGE_PROHIBITED_CONTENT": "The token generation was stopped as the response was flagged for prohibited image content.",
}
_GEMINI_FINISH_REASON_KEYS = frozenset({
"STOP", "MAX_TOKENS", "SAFETY", "RECITATION", "FINISH_REASON_UNSPECIFIED",
"MALFORMED_FUNCTION_CALL", "LANGUAGE", "OTHER", "BLOCKLIST",
"PROHIBITED_CONTENT", "SPII", "IMAGE_SAFETY", "IMAGE_PROHIBITED_CONTENT",
"TOO_MANY_TOOL_CALLS", "MALFORMED_RESPONSE",
})
@staticmethod
def get_finish_reason_mapping() -> Dict[str, OpenAIChatCompletionFinishReason]:
"""
Return Dictionary of finish reasons which indicate response was flagged
and what it means
Return Dictionary of Gemini/Vertex AI finish reasons and their
OpenAI-compatible mappings.
"""
from litellm.litellm_core_utils.core_helpers import _FINISH_REASON_MAP
return {
"FINISH_REASON_UNSPECIFIED": "finish_reason_unspecified",
"STOP": "stop",
"MAX_TOKENS": "length",
"SAFETY": "content_filter",
"RECITATION": "content_filter",
"LANGUAGE": "content_filter",
"OTHER": "content_filter",
"BLOCKLIST": "content_filter",
"PROHIBITED_CONTENT": "content_filter",
"SPII": "content_filter",
"MALFORMED_FUNCTION_CALL": "malformed_function_call", # openai doesn't have a way of representing this
"IMAGE_SAFETY": "content_filter",
"IMAGE_PROHIBITED_CONTENT": "content_filter",
k: v
for k, v in _FINISH_REASON_MAP.items()
if k in VertexGeminiConfig._GEMINI_FINISH_REASON_KEYS
}
def translate_exception_str(self, exception_string: str):
@@ -1769,15 +1779,14 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
chat_completion_message: Optional[ChatCompletionResponseMessage],
finish_reason: Optional[str],
) -> OpenAIChatCompletionFinishReason:
mapped_finish_reason = VertexGeminiConfig.get_finish_reason_mapping()
from litellm.litellm_core_utils.core_helpers import map_finish_reason
if chat_completion_message and chat_completion_message.get("function_call"):
return "function_call"
elif chat_completion_message and chat_completion_message.get("tool_calls"):
return "tool_calls"
elif (
finish_reason and finish_reason in mapped_finish_reason.keys()
): # vertex ai
return mapped_finish_reason[finish_reason]
elif finish_reason:
return map_finish_reason(finish_reason)
else:
return "stop"
@@ -92,24 +92,7 @@ def get_base_url(spec: Dict[str, Any], spec_path: Optional[str] = None) -> str:
"""Extract base URL from OpenAPI spec."""
# OpenAPI 3.x
if "servers" in spec and spec["servers"]:
server_url = spec["servers"][0]["url"]
# If the server URL is relative (starts with /), derive base from spec_path
if server_url.startswith("/") and spec_path:
if spec_path.startswith("http://") or spec_path.startswith("https://"):
# Extract base URL from spec_path (e.g., https://petstore3.swagger.io/api/v3/openapi.json)
# Combine domain with the relative server URL
from urllib.parse import urlparse
parsed = urlparse(spec_path)
base_domain = f"{parsed.scheme}://{parsed.netloc}"
full_base_url = base_domain + server_url
verbose_logger.info(
f"OpenAPI spec has relative server URL '{server_url}'. "
f"Deriving base from spec_path: {full_base_url}"
)
return full_base_url
return server_url
return spec["servers"][0]["url"]
# OpenAPI 2.x (Swagger)
elif "host" in spec:
scheme = spec.get("schemes", ["https"])[0]
@@ -711,7 +711,6 @@ if MCP_AVAILABLE:
Checks both the full tool name and unprefixed version (without server prefix).
This allows users to configure simple tool names regardless of prefixing.
Comparison is case-insensitive to handle OpenAPI operationIds that may be in camelCase.
Args:
tool_name: The tool name to check (may be prefixed like "server-tool_name")
@@ -724,15 +723,13 @@ if MCP_AVAILABLE:
split_server_prefix_from_name,
)
# Normalize filter list to lowercase for case-insensitive comparison
filter_list_lower = [f.lower() for f in filter_list]
if tool_name.lower() in filter_list_lower:
# Check if the full name is in the list
if tool_name in filter_list:
return True
# Check if the unprefixed name is in the list (case-insensitive)
# Check if the unprefixed name is in the list
unprefixed_name, _ = split_server_prefix_from_name(tool_name)
return unprefixed_name.lower() in filter_list_lower
return unprefixed_name in filter_list
def filter_tools_by_allowed_tools(
tools: List[MCPTool],
+6 -16
View File
@@ -108,23 +108,16 @@ def get_key_models(
"""
all_models: List[str] = []
if len(user_api_key_dict.models) > 0:
all_models = list(user_api_key_dict.models) # copy to avoid mutating cached objects
all_models = user_api_key_dict.models
if SpecialModelNames.all_team_models.value in all_models:
all_models = list(user_api_key_dict.team_models) # copy to avoid mutating cached objects
all_models = user_api_key_dict.team_models
if SpecialModelNames.all_proxy_models.value in all_models:
all_models = list(proxy_model_list) # copy to avoid mutating caller's list
if include_model_access_groups:
all_models.extend(model_access_groups.keys())
all_models = proxy_model_list
all_models = _get_models_from_access_groups(
model_access_groups=model_access_groups,
all_models=all_models,
include_model_access_groups=include_model_access_groups,
model_access_groups=model_access_groups, all_models=all_models
)
# deduplicate while preserving order
all_models = list(dict.fromkeys(all_models))
verbose_proxy_logger.debug("ALL KEY MODELS - {}".format(len(all_models)))
return all_models
@@ -148,8 +141,8 @@ def get_team_models(
all_models_set.update(team_models)
if SpecialModelNames.all_proxy_models.value in all_models_set:
all_models_set.update(proxy_model_list)
if include_model_access_groups:
all_models_set.update(model_access_groups.keys())
all_models = list(all_models_set)
all_models = _get_models_from_access_groups(
model_access_groups=model_access_groups,
@@ -157,9 +150,6 @@ def get_team_models(
include_model_access_groups=include_model_access_groups,
)
# deduplicate while preserving order
all_models = list(dict.fromkeys(all_models))
verbose_proxy_logger.debug("ALL TEAM MODELS - {}".format(len(all_models)))
return all_models
+45 -52
View File
@@ -142,47 +142,17 @@ async def get_credentials(
tags=["credential management"],
response_model=CredentialItem,
)
async def get_credential_by_name(
request: Request,
fastapi_response: Response,
credential_name: str = Path(..., description="The credential name, percent-decoded; may contain slashes"),
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
[BETA] endpoint. This might change unexpectedly.
"""
try:
for credential in litellm.credential_list:
if credential.credential_name == credential_name:
masked_credential = CredentialItem(
credential_name=credential.credential_name,
credential_values=_get_masked_values(
credential.credential_values,
unmasked_length=4,
number_of_asterisks=4,
),
credential_info=credential.credential_info,
)
return masked_credential
raise HTTPException(
status_code=404,
detail="Credential not found. Got credential name: " + credential_name,
)
except Exception as e:
verbose_proxy_logger.exception(e)
raise handle_exception_on_proxy(e)
@router.get(
"/credentials/by_model/{model_id}",
dependencies=[Depends(user_api_key_auth)],
tags=["credential management"],
response_model=CredentialItem,
)
async def get_credential_by_model(
async def get_credential(
request: Request,
fastapi_response: Response,
model_id: str = Path(..., description="The model ID to look up credentials for"),
credential_name: str = Path(..., description="The credential name, percent-decoded; may contain slashes"),
model_id: Optional[str] = None,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
@@ -191,25 +161,48 @@ async def get_credential_by_model(
from litellm.proxy.proxy_server import llm_router
try:
if llm_router is None:
raise HTTPException(status_code=500, detail="LLM router not found")
model = llm_router.get_deployment(model_id)
if model is None:
raise HTTPException(status_code=404, detail="Model not found")
credential_values = llm_router.get_deployment_credentials(model_id)
if credential_values is None:
raise HTTPException(status_code=404, detail="Model not found")
masked_credential_values = _get_masked_values(
credential_values,
unmasked_length=4,
number_of_asterisks=4,
)
credential = CredentialItem(
credential_name="{}-credential-{}".format(model.model_name, model_id),
credential_values=masked_credential_values,
credential_info={},
)
return credential
if model_id:
if llm_router is None:
raise HTTPException(status_code=500, detail="LLM router not found")
model = llm_router.get_deployment(model_id)
if model is None:
raise HTTPException(status_code=404, detail="Model not found")
credential_values = llm_router.get_deployment_credentials(model_id)
if credential_values is None:
raise HTTPException(status_code=404, detail="Model not found")
masked_credential_values = _get_masked_values(
credential_values,
unmasked_length=4,
number_of_asterisks=4,
)
credential = CredentialItem(
credential_name="{}-credential-{}".format(model.model_name, model_id),
credential_values=masked_credential_values,
credential_info={},
)
# return credential object
return credential
elif credential_name:
for credential in litellm.credential_list:
if credential.credential_name == credential_name:
masked_credential = CredentialItem(
credential_name=credential.credential_name,
credential_values=_get_masked_values(
credential.credential_values,
unmasked_length=4,
number_of_asterisks=4,
),
credential_info=credential.credential_info,
)
return masked_credential
raise HTTPException(
status_code=404,
detail="Credential not found. Got credential name: " + credential_name,
)
else:
raise HTTPException(
status_code=404, detail="Credential name or model ID required"
)
except Exception as e:
verbose_proxy_logger.exception(e)
raise handle_exception_on_proxy(e)
@@ -19,7 +19,7 @@ def initialize_guardrail(litellm_params: "LitellmParams", guardrail: "Guardrail"
_panw_callback = PanwPrismaAirsHandler(
**{
**litellm_params.model_dump(),
**litellm_params.model_dump(exclude_unset=True),
"guardrail_name": guardrail_name,
"event_hook": litellm_params.mode,
"default_on": litellm_params.default_on or False,
File diff suppressed because it is too large Load Diff
@@ -2827,6 +2827,21 @@ async def validate_membership(
)
def _unfurl_all_proxy_models(
team_info: LiteLLM_TeamTable, llm_router: Router
) -> LiteLLM_TeamTable:
if (
SpecialModelNames.all_proxy_models.value in team_info.models
and llm_router is not None
):
team_models: set[str] = set() # make set to avoid duplicates
for model in team_info.models:
if model != SpecialModelNames.all_proxy_models.value:
team_models.add(model)
for model in llm_router.get_model_names():
team_models.add(model)
team_info.models = list(team_models)
return team_info
async def _add_team_member_budget_table(
@@ -2957,6 +2972,9 @@ async def team_info(
team_info_response_object=_team_info,
)
# ## UNFURL 'all-proxy-models' into the team_info.models list ##
# if llm_router is not None:
# _team_info = _unfurl_all_proxy_models(_team_info, llm_router)
response_object = TeamInfoResponseObject(
team_id=team_id,
team_info=_team_info,
@@ -2062,8 +2062,7 @@ class InitPassThroughEndpointHelpers:
"""
## CHECK IF MAPPED PASS THROUGH ENDPOINT
for mapped_route in LiteLLMRoutes.mapped_pass_through_routes.value:
full_mapped_route = InitPassThroughEndpointHelpers._build_full_path_with_root(mapped_route)
if route.startswith(full_mapped_route):
if route.startswith(mapped_route):
return True
# Fast path: check if any registered route key contains this path
+6
View File
@@ -397,6 +397,9 @@ model LiteLLM_VerificationToken {
// SELECT ... FROM "public"."LiteLLM_VerificationToken" WHERE (("public"."LiteLLM_VerificationToken"."expires" IS NULL OR "public"."LiteLLM_VerificationToken"."expires" > $1) AND "public"."LiteLLM_VerificationToken"."budget_reset_at" < $2) OFFSET $3
@@index([budget_reset_at, expires])
// SELECT ... FROM "public"."LiteLLM_VerificationToken" WHERE (...) ORDER BY "public"."LiteLLM_VerificationToken"."key_alias" ASC
@@index([key_alias])
}
model LiteLLM_JWTKeyMapping {
@@ -562,6 +565,9 @@ model LiteLLM_SpendLogs {
@@index([startTime, request_id])
@@index([end_user])
@@index([session_id])
// SELECT ... FROM "LiteLLM_SpendLogs" WHERE ("startTime" >= $1 AND "startTime" <= $2 AND "user" = $3) GROUP BY ...
@@index([user, startTime])
}
// View spend, model, api_key per request
@@ -1461,21 +1461,11 @@ async def _get_spend_report_for_time_range(
dependencies=[Depends(user_api_key_auth)],
responses={
200: {
"description": "The calculated cost",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"cost": {
"type": "number",
"description": "The calculated cost",
"example": 0.0,
}
},
}
}
},
"cost": {
"description": "The calculated cost",
"example": 0.0,
"type": "float",
}
}
},
)
@@ -292,14 +292,14 @@ class LiteLLMCompletionResponsesConfig:
)
_messages = litellm_completion_request.get("messages") or []
session_messages = chat_completion_session.get("messages") or []
# If session messages are empty (e.g., no database in test environment),
# we still need to process the new input messages
# Store original _messages before combining for safety check
original_new_messages = _messages.copy() if _messages else []
combined_messages = session_messages + _messages
# Fix: Ensure tool_results have corresponding tool_calls in previous assistant message
# Pass tools parameter to help reconstruct tool_calls if not in cache
tools = litellm_completion_request.get("tools") or []
@@ -307,7 +307,7 @@ class LiteLLMCompletionResponsesConfig:
messages=combined_messages,
tools=tools
)
# Safety check: Ensure we don't end up with empty messages
# This can happen when using previous_response_id without a database (e.g., in tests)
# and session messages are empty but new input messages exist
@@ -338,7 +338,7 @@ class LiteLLMCompletionResponsesConfig:
model=litellm_completion_request.get("model", ""),
llm_provider=litellm_completion_request.get("custom_llm_provider", ""),
)
litellm_completion_request["messages"] = combined_messages
litellm_completion_request["litellm_trace_id"] = chat_completion_session.get(
"litellm_session_id"
@@ -384,45 +384,10 @@ class LiteLLMCompletionResponsesConfig:
if call_id_raw:
existing_tool_call_ids.add(str(call_id_raw))
#########################################################
# Merge consecutive function_call items into a single assistant
# message. Anthropic requires that all tool_use blocks appear in
# ONE assistant message immediately followed by the tool_result
# blocks. Without this merging, each function_call creates its own
# assistant message, producing back-to-back assistant messages that
# Anthropic rejects with "tool_use ids were found without
# tool_result blocks immediately after".
#########################################################
if messages:
last_msg = messages[-1]
last_role = (
last_msg.get("role")
if isinstance(last_msg, dict)
else getattr(last_msg, "role", None)
)
if last_role == "assistant":
for new_msg in chat_completion_messages:
new_role = (
new_msg.get("role")
if isinstance(new_msg, dict)
else getattr(new_msg, "role", None)
)
if new_role == "assistant":
new_tcs = (
new_msg.get("tool_calls")
if isinstance(new_msg, dict)
else getattr(new_msg, "tool_calls", None)
) or []
for tc in new_tcs:
LiteLLMCompletionResponsesConfig._add_tool_call_to_assistant(
last_msg, tc
)
continue
#########################################################
# If Input Item is a Tool Call Output, add it to the tool_call_output_messages list
# preserving the ordering of tool call outputs. Some models require the tool
# result to immediately follow the assistant tool call.
# preserving the ordering of tool call outputs. Some models require the tool
# result to immediately follow the assistant tool call.
#########################################################
if LiteLLMCompletionResponsesConfig._is_input_item_tool_call_output(
input_item=_input
@@ -795,14 +760,14 @@ class LiteLLMCompletionResponsesConfig:
]:
"""
Ensure that tool_result messages have corresponding tool_calls in the previous assistant message.
This is critical for Anthropic API which requires that each tool_result block has a
corresponding tool_use block in the previous assistant message.
Args:
messages: List of messages that may include tool_result messages
tools: Optional list of tools that can be used to reconstruct tool_calls if not in cache
Returns:
List of messages with tool_calls added to assistant messages when needed
"""
@@ -821,29 +786,29 @@ class LiteLLMCompletionResponsesConfig:
]
] = list(copy.deepcopy(messages))
messages_to_remove = []
# Count non-tool messages to avoid removing all messages
# This prevents empty messages list when using previous_response_id without a database
non_tool_messages_count = sum(
1 for msg in fixed_messages if msg.get("role") != "tool"
)
for i, message in enumerate(fixed_messages):
# Only process tool messages - check role first to narrow the type
if message.get("role") != "tool":
continue
# At this point, we know it's a tool message, so it should have tool_call_id
# Use get() with default to safely access tool_call_id
tool_call_id_raw = message.get("tool_call_id") if isinstance(message, dict) else getattr(message, "tool_call_id", None)
tool_call_id: str = (
str(tool_call_id_raw) if tool_call_id_raw is not None else ""
)
prev_assistant_idx = LiteLLMCompletionResponsesConfig._find_previous_assistant_idx(
fixed_messages, i
)
# Try to recover empty tool_call_id from previous assistant message
if not tool_call_id and prev_assistant_idx is not None:
prev_assistant = fixed_messages[prev_assistant_idx]
@@ -858,7 +823,7 @@ class LiteLLMCompletionResponsesConfig:
message_dict["tool_call_id"] = tool_call_id
elif hasattr(message, "tool_call_id"):
setattr(message, "tool_call_id", tool_call_id)
# Only remove messages with empty tool_call_id if we have other non-tool messages
# This prevents ending up with an empty messages list when using previous_response_id
# without a database (e.g., in tests where session messages are empty)
@@ -870,7 +835,7 @@ class LiteLLMCompletionResponsesConfig:
# If no non-tool messages, keep the tool message even with empty call_id
# The API will return a proper error message about the missing tool_use block
continue
# Check if the previous assistant message has the corresponding tool_call
# This needs to run for ALL tool messages with a valid tool_call_id,
# not just those that had an empty tool_call_id initially
@@ -879,12 +844,12 @@ class LiteLLMCompletionResponsesConfig:
tool_calls = LiteLLMCompletionResponsesConfig._get_tool_calls_list(
prev_assistant
)
if not LiteLLMCompletionResponsesConfig._check_tool_call_exists(
tool_calls, tool_call_id
):
_tool_use_definition = TOOL_CALLS_CACHE.get_cache(key=tool_call_id)
if not _tool_use_definition and tools:
_tool_use_definition = (
LiteLLMCompletionResponsesConfig._reconstruct_tool_call_from_tools(
@@ -909,11 +874,11 @@ class LiteLLMCompletionResponsesConfig:
LiteLLMCompletionResponsesConfig._add_tool_call_to_assistant(
prev_assistant, tool_call_chunk
)
# Remove messages with empty tool_call_id that couldn't be fixed
for idx in reversed(messages_to_remove):
fixed_messages.pop(idx)
return fixed_messages
@staticmethod
@@ -1558,39 +1523,6 @@ class LiteLLMCompletionResponsesConfig:
return tool_call_dict
@staticmethod
def convert_apply_patch_tool_call_to_chat_completion_tool_call(
tool_call_item: Any,
index: int = 0,
) -> Dict[str, Any]:
"""
Convert ResponseApplyPatchToolCall to ChatCompletionToolCallChunk format.
The operation (create_file / update_file / delete_file) is serialised
as JSON so it appears in function.arguments, just like any other
tool call.
Args:
tool_call_item: ResponseApplyPatchToolCall object with call_id and operation
index: The index of this tool call
Returns:
Dictionary in ChatCompletionToolCallChunk format
"""
import json
operation_dict = tool_call_item.operation.model_dump()
tool_call_dict: Dict[str, Any] = {
"id": tool_call_item.call_id,
"function": {
"name": "apply_patch",
"arguments": json.dumps(operation_dict),
},
"type": "function",
"index": index,
}
return tool_call_dict
@staticmethod
def transform_chat_completion_response_to_responses_api_response(
request_input: Union[str, ResponseInputParam],
+6 -6
View File
@@ -686,7 +686,7 @@ def responses(
BaseResponsesAPIConfig
] = ProviderConfigManager.get_provider_responses_api_config(
model=model,
provider=litellm.LlmProviders(custom_llm_provider),
provider=custom_llm_provider,
)
local_vars.update(kwargs)
@@ -902,7 +902,7 @@ def delete_responses(
BaseResponsesAPIConfig
] = ProviderConfigManager.get_provider_responses_api_config(
model=None,
provider=litellm.LlmProviders(custom_llm_provider),
provider=custom_llm_provider,
)
if responses_api_provider_config is None:
@@ -1082,7 +1082,7 @@ def get_responses(
BaseResponsesAPIConfig
] = ProviderConfigManager.get_provider_responses_api_config(
model=None,
provider=litellm.LlmProviders(custom_llm_provider),
provider=custom_llm_provider,
)
if responses_api_provider_config is None:
@@ -1239,7 +1239,7 @@ def list_input_items(
BaseResponsesAPIConfig
] = ProviderConfigManager.get_provider_responses_api_config(
model=None,
provider=litellm.LlmProviders(custom_llm_provider),
provider=custom_llm_provider,
)
if responses_api_provider_config is None:
@@ -1397,7 +1397,7 @@ def cancel_responses(
BaseResponsesAPIConfig
] = ProviderConfigManager.get_provider_responses_api_config(
model=None,
provider=litellm.LlmProviders(custom_llm_provider),
provider=custom_llm_provider,
)
if responses_api_provider_config is None:
@@ -1584,7 +1584,7 @@ def compact_responses(
BaseResponsesAPIConfig
] = ProviderConfigManager.get_provider_responses_api_config(
model=model,
provider=litellm.LlmProviders(custom_llm_provider),
provider=custom_llm_provider,
)
if responses_api_provider_config is None:
-22
View File
@@ -5505,10 +5505,6 @@ class Router:
return response
except Exception as e:
# Always track the latest error so we raise the most
# recent exception instead of the first one.
original_exception = e
## LOGGING
kwargs = self.log_retry(kwargs=kwargs, e=e)
remaining_retries = num_retries - current_attempt - 1
@@ -5523,24 +5519,6 @@ class Router:
)
else:
_healthy_deployments = []
# Check if this error is non-retryable (e.g., 400 context
# window exceeded). If so, raise immediately instead of
# continuing the retry loop. Respect retry policy
# precedence - only check when no retry policy applies.
if not _retry_policy_applies:
try:
self.should_retry_this_error(
error=e,
healthy_deployments=_healthy_deployments,
all_deployments=_all_deployments,
context_window_fallbacks=context_window_fallbacks,
regular_fallbacks=fallbacks,
content_policy_fallbacks=content_policy_fallbacks,
)
except Exception:
raise e
_timeout = self._time_to_sleep_before_retry(
e=e,
remaining_retries=remaining_retries,
+3 -5
View File
@@ -490,22 +490,20 @@ class LowestLatencyLoggingHandler(CustomLogger):
# get average latency or average ttft (depending on streaming/non-streaming)
total: float = 0.0
use_ttft = (
if (
request_kwargs is not None
and request_kwargs.get("stream", None) is not None
and request_kwargs["stream"] is True
and len(item_ttft_latency) > 0
)
if use_ttft:
):
for _call_latency in item_ttft_latency:
if isinstance(_call_latency, float):
total += _call_latency
item_latency = total / len(item_ttft_latency)
else:
for _call_latency in item_latency:
if isinstance(_call_latency, float):
total += _call_latency
item_latency = total / len(item_latency)
item_latency = total / len(item_latency)
# -------------- #
# Debugging Logic
+1
View File
@@ -13,6 +13,7 @@ class ImageEditOptionalRequestParams(TypedDict, total=False):
"""
background: Optional[Literal["transparent", "opaque", "auto"]]
input_fidelity: Optional[Literal["high", "low"]]
mask: Optional[str]
n: Optional[int]
quality: Optional[Literal["high", "medium", "low", "standard", "auto"]]
+9 -1
View File
@@ -1199,6 +1199,14 @@ class ResponseAPIUsage(BaseLiteLLMOpenAIResponseObject):
cost: Optional[float] = None
"""The cost of the request."""
@field_validator("cost", mode="before")
@classmethod
def parse_cost(cls, v: Any) -> Optional[float]:
"""Normalise cost: accept either a float or a dict with a ``total_cost`` key."""
if isinstance(v, dict):
return v.get("total_cost")
return v
model_config = {"extra": "allow"}
@@ -2110,7 +2118,7 @@ class OpenAIBatchResult(TypedDict, total=False):
OpenAIChatCompletionFinishReason = Literal[
"stop", "content_filter", "function_call", "tool_calls", "length", "guardrail_intervened", "eos", "finish_reason_unspecified", "malformed_function_call" # last 2 are vertex ai specific, guardrail_intervened is bedrock specific
"stop", "content_filter", "function_call", "tool_calls", "length"
]
@@ -52,6 +52,13 @@ class PanwPrismaAirsGuardrailConfigModel(GuardrailConfigModel):
description="PANW API call timeout in seconds (1-60).",
)
experimental_use_latest_role_message_only: Optional[bool] = Field(
default=None,
description="Anthropic /v1/messages only. When unset: scans only latest user/developer "
"message on request side. Set false to scan all user/system/developer messages. "
"Non-Anthropic unaffected.",
)
@staticmethod
def ui_friendly_name() -> str:
return "PANW Prisma AIRS"
+5 -1
View File
@@ -1333,7 +1333,11 @@ class Choices(SafeAttributeModel, OpenAIObject):
**params,
):
if finish_reason is not None:
params["finish_reason"] = map_finish_reason(finish_reason)
mapped = map_finish_reason(finish_reason)
params["finish_reason"] = mapped
if finish_reason != mapped:
provider_specific_fields = dict(provider_specific_fields) if provider_specific_fields else {}
provider_specific_fields["native_finish_reason"] = finish_reason
else:
params["finish_reason"] = "stop"
if index is not None:
+61 -21
View File
@@ -8101,17 +8101,8 @@ class ProviderConfigManager:
Returns the provider config for a given provider.
Uses O(1) dictionary lookup for fast provider resolution.
Python classes take priority over JSON (they have custom overrides).
"""
# Check JSON providers FIRST (these override standard mappings)
from litellm.llms.openai_like.dynamic_config import create_config_class
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
if JSONProviderRegistry.exists(provider.value):
provider_config = JSONProviderRegistry.get(provider.value)
if provider_config is None:
raise ValueError(f"Provider {provider.value} not found")
return create_config_class(provider_config)()
# Handle OpenAI special cases (O-series and GPT-5 models)
if provider == LlmProviders.OPENAI:
if litellm.openaiOSeriesConfig.is_model_o_series_model(model=model):
@@ -8125,18 +8116,24 @@ class ProviderConfigManager:
ProviderConfigManager._build_provider_config_map()
)
# O(1) dictionary lookup
# O(1) dictionary lookup — Python classes first (custom overrides take priority)
config_entry = ProviderConfigManager._PROVIDER_CONFIG_MAP.get(provider)
if config_entry is None:
return None
if config_entry is not None:
config_factory, needs_model = config_entry
if needs_model:
return config_factory(model) # type: ignore
else:
return config_factory() # type: ignore
# Unpack factory function and whether it needs model parameter
# This avoids expensive inspect.signature() calls at runtime
config_factory, needs_model = config_entry
if needs_model:
return config_factory(model) # type: ignore
else:
return config_factory() # type: ignore
# Fall back to JSON providers (generic OpenAI-compatible)
from litellm.llms.openai_like.dynamic_config import create_config_class
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
if JSONProviderRegistry.exists(provider.value):
provider_config = JSONProviderRegistry.get(provider.value)
if provider_config is None:
raise ValueError(f"Provider {provider.value} not found")
return create_config_class(provider_config)()
@staticmethod
def get_provider_embedding_config(
@@ -8341,9 +8338,52 @@ class ProviderConfigManager:
@staticmethod
def get_provider_responses_api_config(
provider: LlmProviders,
provider: Union[LlmProviders, str],
model: Optional[str] = None,
) -> Optional[BaseResponsesAPIConfig]:
from litellm.llms.openai_like.dynamic_config import (
create_responses_config_class,
)
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
# Resolve provider string for JSON lookup
provider_str = provider.value if isinstance(provider, LlmProviders) else str(provider)
# Try to convert to enum for Python class lookup first.
# Python classes take priority over JSON (they have custom overrides).
provider_enum: Optional[LlmProviders] = None
if isinstance(provider, LlmProviders):
provider_enum = provider
else:
try:
provider_enum = LlmProviders(provider)
except ValueError:
pass
# Check Python classes first (custom overrides take priority)
result = ProviderConfigManager._get_python_responses_api_config(
provider_enum, model
)
if result is not None:
return result
# Fall back to JSON providers (generic OpenAI-compatible)
if JSONProviderRegistry.exists(provider_str) and JSONProviderRegistry.supports_responses_api(provider_str):
provider_config = JSONProviderRegistry.get(provider_str)
if provider_config is not None:
return create_responses_config_class(provider_config)()
return None
@staticmethod
def _get_python_responses_api_config(
provider: Optional[LlmProviders],
model: Optional[str] = None,
) -> Optional[BaseResponsesAPIConfig]:
"""Check for Python-class-based responses API configs (custom overrides)."""
if provider is None:
return None
if litellm.LlmProviders.OPENAI == provider:
return litellm.OpenAIResponsesAPIConfig()
elif litellm.LlmProviders.AZURE == provider:
-18
View File
@@ -458,24 +458,6 @@
"interactions": true
}
},
"charity_engine": {
"display_name": "Charity Engine (`charity_engine`)",
"url": "https://docs.litellm.ai/docs/providers/charity_engine",
"endpoints": {
"chat_completions": true,
"messages": true,
"responses": true,
"embeddings": false,
"image_generations": false,
"audio_transcriptions": false,
"audio_speech": false,
"moderations": false,
"batches": false,
"rerank": false,
"a2a": false,
"interactions": false
}
},
"chutes": {
"display_name": "Chutes (`chutes`)",
"endpoints": {
+6
View File
@@ -388,6 +388,9 @@ model LiteLLM_VerificationToken {
// SELECT ... FROM "public"."LiteLLM_VerificationToken" WHERE (("public"."LiteLLM_VerificationToken"."expires" IS NULL OR "public"."LiteLLM_VerificationToken"."expires" > $1) AND "public"."LiteLLM_VerificationToken"."budget_reset_at" < $2) OFFSET $3
@@index([budget_reset_at, expires])
// SELECT ... FROM "public"."LiteLLM_VerificationToken" WHERE (...) ORDER BY "public"."LiteLLM_VerificationToken"."key_alias" ASC
@@index([key_alias])
}
model LiteLLM_JWTKeyMapping {
@@ -553,6 +556,9 @@ model LiteLLM_SpendLogs {
@@index([startTime, request_id])
@@index([end_user])
@@index([session_id])
// SELECT ... FROM "LiteLLM_SpendLogs" WHERE ("startTime" >= $1 AND "startTime" <= $2 AND "user" = $3) GROUP BY ...
@@index([user, startTime])
}
// View spend, model, api_key per request
@@ -1601,3 +1601,346 @@ def test_parse_tool_call_arguments_still_raises_for_unrepairable():
error_msg = str(exc_info.value)
assert "test_tool" in error_msg
assert "test context" in error_msg
def test_anthropic_messages_pt_interleave_thinking_with_server_tool_calls():
"""
Test that thinking blocks are interleaved with server tool calls (web search)
instead of being prepended all at once.
When Anthropic returns a response with extended thinking + multiple web searches,
the content blocks are interleaved:
[thinking_1, server_tool_use_1, result_1, thinking_2, server_tool_use_2, result_2]
On round-trip through OpenAI format, thinking_blocks and tool_calls are separate
fields. anthropic_messages_pt must reconstruct the interleaved order, otherwise
Anthropic rejects the request because thinking block signatures are position-dependent.
Fixes: https://github.com/BerriAI/litellm/issues/23047
"""
messages = [
{"role": "user", "content": "Search for news about fast.ai and answer.ai"},
{
"role": "assistant",
"content": "Here is what I found.",
"thinking_blocks": [
{
"type": "thinking",
"thinking": "I need to search for fast.ai news.",
"signature": "sig_thinking_1",
},
{
"type": "thinking",
"thinking": "Now I should also search for answer.ai.",
"signature": "sig_thinking_2",
},
],
"tool_calls": [
{
"id": "srvtoolu_01SEARCH1",
"type": "function",
"function": {
"name": "web_search",
"arguments": '{"query": "fast.ai news"}',
},
},
{
"id": "srvtoolu_01SEARCH2",
"type": "function",
"function": {
"name": "web_search",
"arguments": '{"query": "answer.ai news"}',
},
},
],
"provider_specific_fields": {
"web_search_results": [
{
"type": "web_search_tool_result",
"tool_use_id": "srvtoolu_01SEARCH1",
"content": [
{
"type": "web_search_result",
"url": "https://fast.ai",
"title": "fast.ai",
"snippet": "fast.ai news",
}
],
},
{
"type": "web_search_tool_result",
"tool_use_id": "srvtoolu_01SEARCH2",
"content": [
{
"type": "web_search_result",
"url": "https://answer.ai",
"title": "answer.ai",
"snippet": "answer.ai news",
}
],
},
]
},
},
{"role": "user", "content": "Now search for news about solveit"},
]
result = anthropic_messages_pt(
messages, model="claude-sonnet-4-5", llm_provider="anthropic"
)
# Find the assistant message
assistant_msg = next(m for m in result if m["role"] == "assistant")
content = assistant_msg["content"]
# Extract types in order
types = [c.get("type") for c in content]
# The correct interleaved order should be:
# thinking_1, server_tool_use_1, web_search_tool_result_1,
# thinking_2, server_tool_use_2, web_search_tool_result_2,
# text
assert types == [
"thinking",
"server_tool_use",
"web_search_tool_result",
"thinking",
"server_tool_use",
"web_search_tool_result",
"text",
], f"Expected interleaved order but got: {types}"
# Verify thinking blocks preserved their content and signatures
thinking_1 = content[0]
assert thinking_1["thinking"] == "I need to search for fast.ai news."
assert thinking_1["signature"] == "sig_thinking_1"
thinking_2 = content[3]
assert thinking_2["thinking"] == "Now I should also search for answer.ai."
assert thinking_2["signature"] == "sig_thinking_2"
# Verify server_tool_use blocks preserved their IDs
assert content[1]["id"] == "srvtoolu_01SEARCH1"
assert content[4]["id"] == "srvtoolu_01SEARCH2"
# Verify web_search_tool_result blocks are paired correctly
assert content[2]["tool_use_id"] == "srvtoolu_01SEARCH1"
assert content[5]["tool_use_id"] == "srvtoolu_01SEARCH2"
# Verify text block is present at the end
assert content[6]["text"] == "Here is what I found."
def test_anthropic_messages_pt_thinking_blocks_no_server_tools_unchanged():
"""
Test that the existing behavior is preserved when thinking blocks exist
but there are no server tool calls (only regular tool_use).
Thinking blocks should still be prepended first in this case.
"""
messages = [
{"role": "user", "content": "What is the weather?"},
{
"role": "assistant",
"content": "Let me check.",
"thinking_blocks": [
{
"type": "thinking",
"thinking": "I should check the weather.",
"signature": "sig_1",
},
],
"tool_calls": [
{
"id": "toolu_01REG",
"type": "function",
"function": {
"name": "get_weather",
"arguments": '{"location": "SF"}',
},
},
],
},
{
"role": "tool",
"tool_call_id": "toolu_01REG",
"content": "72F and sunny",
},
]
result = anthropic_messages_pt(
messages, model="claude-sonnet-4-5", llm_provider="anthropic"
)
assistant_msg = next(m for m in result if m["role"] == "assistant")
content = assistant_msg["content"]
types = [c.get("type") for c in content]
# Original behavior: thinking first, then text, then tool_use
assert types == ["thinking", "text", "tool_use"], f"Expected sequential order but got: {types}"
def test_anthropic_messages_pt_interleave_more_thinking_than_tool_groups():
"""
Test interleaving when there are more thinking blocks than server tool groups.
Extra thinking blocks should appear before the text block.
"""
messages = [
{"role": "user", "content": "Search for something"},
{
"role": "assistant",
"content": "Found it.",
"thinking_blocks": [
{
"type": "thinking",
"thinking": "First thought",
"signature": "sig_1",
},
{
"type": "thinking",
"thinking": "Second thought",
"signature": "sig_2",
},
{
"type": "thinking",
"thinking": "Third thought after search",
"signature": "sig_3",
},
],
"tool_calls": [
{
"id": "srvtoolu_01ONLY",
"type": "function",
"function": {
"name": "web_search",
"arguments": '{"query": "something"}',
},
},
],
"provider_specific_fields": {
"web_search_results": [
{
"type": "web_search_tool_result",
"tool_use_id": "srvtoolu_01ONLY",
"content": [{"type": "web_search_result", "url": "https://example.com", "title": "Test", "snippet": "result"}],
},
]
},
},
]
result = anthropic_messages_pt(
messages, model="claude-sonnet-4-5", llm_provider="anthropic"
)
assistant_msg = next(m for m in result if m["role"] == "assistant")
content = assistant_msg["content"]
types = [c.get("type") for c in content]
# thinking_1 paired with tool group, thinking_2 and thinking_3 before text
assert types == [
"thinking", # paired with tool group
"server_tool_use",
"web_search_tool_result",
"thinking", # extra - before text
"thinking", # extra - before text
"text",
], f"Expected order but got: {types}"
def test_anthropic_messages_pt_list_content_with_thinking_preserves_order():
"""
Test that when assistant content is already a list containing interleaved
thinking blocks and server tool blocks, the thinking_blocks from
provider_specific_fields are NOT duplicated/prepended.
This covers the gap identified by Greptile where list-content messages
bypass INTERLEAVED MODE and fall into SEQUENTIAL MODE, which previously
would prepend all thinking_blocks again, causing duplication and
breaking Anthropic's position-dependent signature verification.
Fixes: https://github.com/BerriAI/litellm/issues/23047
"""
messages = [
{"role": "user", "content": "Search for AI news"},
{
"role": "assistant",
# Content is already a list with interleaved thinking + server tool blocks
"content": [
{
"type": "thinking",
"thinking": "Let me search for AI news.",
"signature": "sig_1",
},
{
"type": "server_tool_use",
"id": "srvtoolu_01SEARCH1",
"name": "web_search",
"input": {"query": "AI news"},
},
{
"type": "web_search_tool_result",
"tool_use_id": "srvtoolu_01SEARCH1",
"content": [
{
"type": "web_search_result",
"url": "https://example.com",
"title": "AI News",
"snippet": "Latest AI news",
}
],
},
{
"type": "thinking",
"thinking": "Now let me summarize.",
"signature": "sig_2",
},
{
"type": "text",
"text": "Here is the AI news summary.",
},
],
# thinking_blocks also present in provider_specific_fields
"thinking_blocks": [
{
"type": "thinking",
"thinking": "Let me search for AI news.",
"signature": "sig_1",
},
{
"type": "thinking",
"thinking": "Now let me summarize.",
"signature": "sig_2",
},
],
},
{"role": "user", "content": "Tell me more"},
]
result = anthropic_messages_pt(
messages, model="claude-sonnet-4-5", llm_provider="anthropic"
)
assistant_msg = next(m for m in result if m["role"] == "assistant")
content = assistant_msg["content"]
types = [c.get("type") for c in content]
# The list content already has the correct interleaved order.
# thinking_blocks should NOT be prepended again (which would cause
# duplication and break signature verification).
assert types == [
"thinking",
"server_tool_use",
"web_search_tool_result",
"thinking",
"text",
], f"Expected preserved list order without duplicate thinking blocks, but got: {types}"
# Verify no duplicate thinking blocks
thinking_count = sum(1 for t in types if t == "thinking")
assert thinking_count == 2, f"Expected 2 thinking blocks, got {thinking_count} (duplication detected)"
# Verify signatures preserved in correct positions
assert content[0]["signature"] == "sig_1"
assert content[3]["signature"] == "sig_2"
+5 -11
View File
@@ -44,25 +44,19 @@ def create_skill_zip(skill_name: str, unique_suffix: Optional[str] = None):
skill_dir = test_dir / skill_name
# Create a zip file containing the skill directory
# When unique_suffix is set, folder name must match skill name in SKILL.md (Anthropic requirement)
zip_folder_name = f"{skill_name}-{unique_suffix}" if unique_suffix else skill_name
zip_path = test_dir / f"{skill_name}.zip"
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
zf.write(skill_dir, arcname=skill_name)
if unique_suffix is not None:
# Rewrite SKILL.md with a unique name and use matching folder name
# Rewrite SKILL.md with a unique name to avoid API conflicts
skill_md = (skill_dir / "SKILL.md").read_text()
skill_md = skill_md.replace(
f"name: {skill_name}",
f"name: {zip_folder_name}",
f"name: {skill_name}-{unique_suffix}",
)
zf.writestr(f"{zip_folder_name}/SKILL.md", skill_md)
# Add any other files in the skill dir (e.g. subdirs) under the new folder name
for f in skill_dir.rglob("*"):
if f.is_file() and f.name != "SKILL.md":
rel = f.relative_to(skill_dir)
zf.write(f, arcname=f"{zip_folder_name}/{rel}")
zf.writestr(f"{skill_name}/SKILL.md", skill_md)
else:
zf.write(skill_dir, arcname=skill_name)
zf.write(skill_dir / "SKILL.md", arcname=f"{skill_name}/SKILL.md")
try:
@@ -1300,11 +1300,9 @@ def test_logging_async_cache_hit_sync_call(turn_off_message_logging):
"redacted-by-litellm"
== standard_logging_object["messages"][0]["content"]
)
# response is a full ModelResponse dict (choices format) since d84e5e381acf
assert (
standard_logging_object["response"]["choices"][0]["message"]["content"]
== "redacted-by-litellm"
)
assert {"text": "redacted-by-litellm"} == standard_logging_object[
"response"
]
def test_logging_standard_payload_failure_call():
@@ -45,8 +45,7 @@ async def test_global_redaction_on():
await asyncio.sleep(1)
standard_logging_payload = test_custom_logger.logged_standard_logging_payload
assert standard_logging_payload is not None
response = standard_logging_payload["response"]
assert response["choices"][0]["message"]["content"] == "redacted-by-litellm"
assert standard_logging_payload["response"] == {"text": "redacted-by-litellm"}
assert standard_logging_payload["messages"][0]["content"] == "redacted-by-litellm"
print(
"logged standard logging payload",
@@ -76,8 +75,7 @@ async def test_global_redaction_with_dynamic_params(turn_off_message_logging):
)
if turn_off_message_logging is True:
response = standard_logging_payload["response"]
assert response["choices"][0]["message"]["content"] == "redacted-by-litellm"
assert standard_logging_payload["response"] == {"text": "redacted-by-litellm"}
assert (
standard_logging_payload["messages"][0]["content"] == "redacted-by-litellm"
)
@@ -110,8 +108,7 @@ async def test_global_redaction_off_with_dynamic_params(turn_off_message_logging
json.dumps(standard_logging_payload, indent=2),
)
if turn_off_message_logging is True:
response = standard_logging_payload["response"]
assert response["choices"][0]["message"]["content"] == "redacted-by-litellm"
assert standard_logging_payload["response"] == {"text": "redacted-by-litellm"}
assert (
standard_logging_payload["messages"][0]["content"] == "redacted-by-litellm"
)
@@ -393,8 +390,7 @@ async def test_redaction_with_streaming_response():
assert standard_logging_payload is not None
# Verify that redaction worked without pickle errors
response = standard_logging_payload["response"]
assert response["choices"][0]["message"]["content"] == "redacted-by-litellm"
assert standard_logging_payload["response"] == {"text": "redacted-by-litellm"}
assert standard_logging_payload["messages"][0]["content"] == "redacted-by-litellm"
print(
"logged standard logging payload for streaming with coroutine handling",
@@ -481,6 +477,5 @@ async def test_redaction_with_metadata_completion_api():
# Verify the helper function works correctly - with get_metadata_variable_name_from_kwargs,
# the system checks the appropriate field for headers
response = standard_logging_payload["response"]
assert response["choices"][0]["message"]["content"] == "redacted-by-litellm"
assert standard_logging_payload["response"] == {"text": "redacted-by-litellm"}
assert standard_logging_payload["messages"][0]["content"] == "redacted-by-litellm"
@@ -1,11 +1,9 @@
import asyncio
import time
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from litellm.caching.dual_cache import DualCache
from litellm.caching.in_memory_cache import InMemoryCache
from litellm.caching.redis_cache import RedisCache
@@ -58,104 +56,3 @@ async def test_dual_cache_async_batch_get_cache_rolls_back_redis_reservation_on_
assert mock_async_batch_get_cache.call_count == 2
assert "shared_a" not in dual_cache.last_redis_batch_access_time
assert "shared_b" not in dual_cache.last_redis_batch_access_time
@pytest.mark.asyncio
async def test_dual_cache_async_set_cache_injects_default_in_memory_ttl():
"""
Test that async_set_cache injects default_in_memory_ttl into kwargs
when no explicit ttl is provided, matching the sync set_cache behavior.
Regression test for: async_set_cache was missing the TTL injection that
sync set_cache has, causing InMemoryCache to use its own default_ttl (600s)
instead of DualCache's default_in_memory_ttl.
"""
in_memory_cache = InMemoryCache(default_ttl=600)
dual_cache = DualCache(
in_memory_cache=in_memory_cache,
default_in_memory_ttl=60,
)
before = time.time()
await dual_cache.async_set_cache(key="test_key", value="test_value")
after = time.time()
# The TTL stored should reflect default_in_memory_ttl (60s), not
# InMemoryCache's default_ttl (600s)
expiry = in_memory_cache.ttl_dict["test_key"]
assert expiry >= before + 60
assert expiry <= after + 60
@pytest.mark.asyncio
async def test_dual_cache_async_set_cache_respects_explicit_ttl():
"""
Test that async_set_cache does NOT override an explicitly provided ttl.
"""
in_memory_cache = InMemoryCache(default_ttl=600)
dual_cache = DualCache(
in_memory_cache=in_memory_cache,
default_in_memory_ttl=60,
)
before = time.time()
await dual_cache.async_set_cache(key="test_key", value="test_value", ttl=30)
after = time.time()
# The explicit ttl=30 should be used, not default_in_memory_ttl (60)
expiry = in_memory_cache.ttl_dict["test_key"]
assert expiry >= before + 30
assert expiry <= after + 30
@pytest.mark.asyncio
async def test_dual_cache_async_set_cache_pipeline_injects_default_in_memory_ttl():
"""
Test that async_set_cache_pipeline injects default_in_memory_ttl into kwargs
when no explicit ttl is provided.
"""
in_memory_cache = InMemoryCache(default_ttl=600)
dual_cache = DualCache(
in_memory_cache=in_memory_cache,
default_in_memory_ttl=60,
)
cache_list = [("key_a", "value_a"), ("key_b", "value_b")]
before = time.time()
await dual_cache.async_set_cache_pipeline(cache_list=cache_list)
after = time.time()
for key in ["key_a", "key_b"]:
expiry = in_memory_cache.ttl_dict[key]
assert expiry >= before + 60
assert expiry <= after + 60
@pytest.mark.asyncio
async def test_dual_cache_sync_and_async_set_cache_use_same_ttl():
"""
Test that sync set_cache and async async_set_cache produce the same TTL
when no explicit ttl is provided, ensuring parity between the two paths.
"""
in_memory_sync = InMemoryCache(default_ttl=600)
dual_cache_sync = DualCache(
in_memory_cache=in_memory_sync,
default_in_memory_ttl=60,
)
in_memory_async = InMemoryCache(default_ttl=600)
dual_cache_async = DualCache(
in_memory_cache=in_memory_async,
default_in_memory_ttl=60,
)
dual_cache_sync.set_cache(key="test_key", value="test_value")
await dual_cache_async.async_set_cache(key="test_key", value="test_value")
sync_expiry = in_memory_sync.ttl_dict["test_key"]
async_expiry = in_memory_async.ttl_dict["test_key"]
# Both should use default_in_memory_ttl=60, so their expiry times
# should be within a small tolerance of each other
assert abs(sync_expiry - async_expiry) < 1.0
@@ -738,58 +738,7 @@ def test_response_completed_with_message_only_emits_stop_finish_reason():
)
def test_response_completed_preserves_usage_with_cached_tokens():
"""
Test that response.completed correctly translates Responses API usage
(input_tokens_details) to chat completion usage (prompt_tokens_details).
This is a regression test for an issue where streaming with models that
use the Responses API bridge (e.g. gpt-5.2-codex) would drop
prompt_tokens_details, causing cached_tokens to always be None.
"""
from litellm.completion_extras.litellm_responses_transformation.transformation import (
OpenAiResponsesToChatCompletionStreamIterator,
)
iterator = OpenAiResponsesToChatCompletionStreamIterator(streaming_response=None, sync_stream=True)
chunk = {
"type": "response.completed",
"response": {
"id": "resp_789",
"status": "completed",
"output": [
{
"type": "message",
"id": "msg_abc",
"role": "assistant",
"content": [{"type": "output_text", "text": "Six"}],
"status": "completed",
}
],
"usage": {
"input_tokens": 1226,
"output_tokens": 5,
"total_tokens": 1231,
"input_tokens_details": {"cached_tokens": 1024},
"output_tokens_details": {"reasoning_tokens": 0},
},
},
}
result = iterator.chunk_parser(chunk)
assert result.usage is not None, "usage should be set on response.completed chunk"
assert result.usage.prompt_tokens == 1226, "prompt_tokens should map from input_tokens"
assert result.usage.completion_tokens == 5, "completion_tokens should map from output_tokens"
assert result.usage.prompt_tokens_details is not None, "prompt_tokens_details should be set"
assert result.usage.prompt_tokens_details.cached_tokens == 1024, (
"cached_tokens should be preserved from input_tokens_details"
)
def test_function_call_done_emits_is_finished():
def test_function_call_done_does_not_emit_finish_reason():
"""
Test that OUTPUT_ITEM_DONE for a function_call does NOT emit finish_reason.
The response.completed event handles the terminal finish_reason correctly.
@@ -1378,138 +1327,6 @@ def test_transform_response_preserves_annotations():
print("✓ Annotations from Responses API are correctly preserved in Chat Completions format")
def test_apply_patch_tool_call_converted_to_chat_completion_tool_call():
"""
Test that ResponseApplyPatchToolCall items from the Responses API are
correctly converted to ChatCompletions-style tool calls by the bridge.
This is a regression test for a bug where litellm.completion() with a
responses/ model prefix crashed when the model returned an
apply_patch_call, because _convert_response_output_to_choices did not
handle ResponseApplyPatchToolCall items. The model DID use the tool,
but the bridge silently dropped it (or raised an error), while the
native litellm.responses() path worked correctly.
"""
import json
from unittest.mock import Mock
from openai.types.responses.response_apply_patch_tool_call import (
OperationCreateFile,
)
from openai.types.responses.response_output_item import (
ResponseApplyPatchToolCall,
)
from litellm.completion_extras.litellm_responses_transformation.transformation import (
LiteLLMResponsesTransformationHandler,
)
from litellm.types.llms.openai import (
InputTokensDetails,
OutputTokensDetails,
ResponseAPIUsage,
ResponsesAPIResponse,
)
from litellm.types.utils import ModelResponse, Usage
handler = LiteLLMResponsesTransformationHandler()
# Build an apply_patch_call item like the model would return
operation = OperationCreateFile(
diff="--- /dev/null\n+++ b/hello.py\n@@ -0,0 +1 @@\n+print('hello world')\n",
path="hello.py",
type="create_file",
)
apply_patch_item = ResponseApplyPatchToolCall(
id="apc_001",
call_id="call_patch_hello",
operation=operation,
status="completed",
type="apply_patch_call",
)
# Minimal usage
usage = ResponseAPIUsage(
input_tokens=30,
input_tokens_details=InputTokensDetails(cached_tokens=0),
output_tokens=40,
output_tokens_details=OutputTokensDetails(reasoning_tokens=0),
total_tokens=70,
)
raw_response = ResponsesAPIResponse(
id="resp_apply_patch_test",
created_at=1234567890,
error=None,
incomplete_details=None,
instructions=None,
metadata={},
model="gpt-5.2-codex",
object="response",
output=[apply_patch_item],
parallel_tool_calls=True,
temperature=1.0,
tool_choice="auto",
tools=[],
top_p=1.0,
max_output_tokens=None,
previous_response_id=None,
reasoning=None,
status="completed",
text=None,
truncation="disabled",
usage=usage,
user=None,
store=True,
background=False,
)
model_response = ModelResponse(
id="chatcmpl-apply-patch",
created=1234567890,
model=None,
object="chat.completion",
choices=[],
usage=Usage(completion_tokens=0, prompt_tokens=0, total_tokens=0),
)
logging_obj = Mock()
result = handler.transform_response(
model="gpt-5.2-codex",
raw_response=raw_response,
model_response=model_response,
logging_obj=logging_obj,
request_data={"model": "gpt-5.2-codex"},
messages=[
{"role": "system", "content": "You are a coding assistant."},
{"role": "user", "content": "Create hello.py"},
],
optional_params={},
litellm_params={},
encoding=Mock(),
)
# Should have exactly one choice with finish_reason="tool_calls"
assert len(result.choices) == 1, f"Expected 1 choice, got {len(result.choices)}"
choice = result.choices[0]
assert choice.finish_reason == "tool_calls"
# The choice should contain one tool call for apply_patch
tool_calls = choice.message.tool_calls
assert tool_calls is not None, "tool_calls should not be None"
assert len(tool_calls) == 1, f"Expected 1 tool_call, got {len(tool_calls)}"
tc = tool_calls[0]
assert tc["id"] == "call_patch_hello"
assert tc["type"] == "function"
assert tc["function"]["name"] == "apply_patch"
# The operation should be serialised as JSON in arguments
args = json.loads(tc["function"]["arguments"])
assert args["type"] == "create_file"
assert args["path"] == "hello.py"
assert "print('hello world')" in args["diff"]
def test_multi_tool_call_stream_no_premature_finish():
"""
Regression test for multi-tool-call streaming bug.
@@ -1961,35 +1778,3 @@ def test_parallel_tool_calls_comprehensive_streaming_integration():
)
print("✓ Parallel tool calls with split argument deltas stream correctly end-to-end")
def test_map_optional_params_preserves_reasoning_summary():
"""Test that reasoning_effort dict with summary field is preserved.
Regression test for: User reported that summary field was being dropped
when routing to Responses API. The dict format should be fully preserved.
"""
from litellm.completion_extras.litellm_responses_transformation.transformation import (
LiteLLMResponsesTransformationHandler,
)
from litellm.types.llms.openai import ResponsesAPIOptionalRequestParams
handler = LiteLLMResponsesTransformationHandler()
optional_params = {
"stream": False,
"tools": [{"type": "function", "function": {"name": "test_tool"}}],
"tool_choice": "auto",
"reasoning_effort": {"effort": "high", "summary": "detailed"},
}
responses_api_request = ResponsesAPIOptionalRequestParams()
handler._map_optional_params_to_responses_api_request(
optional_params, responses_api_request
)
# Verify reasoning_effort dict with summary was fully preserved
assert "reasoning" in responses_api_request
assert responses_api_request["reasoning"] == {"effort": "high", "summary": "detailed"}
assert responses_api_request["reasoning"]["effort"] == "high"
assert responses_api_request["reasoning"]["summary"] == "detailed"
@@ -1,6 +1,12 @@
"""Tests for litellm_core_utils.core_helpers module."""
from litellm.litellm_core_utils.core_helpers import reconstruct_model_name
import pytest
from litellm.litellm_core_utils.core_helpers import (
_FINISH_REASON_MAP,
map_finish_reason,
reconstruct_model_name,
)
def test_reconstruct_model_name_prefers_deployment_value():
@@ -43,3 +49,102 @@ def test_reconstruct_model_name_returns_original_for_other_providers():
)
assert result == "claude-3-sonnet"
# ---------------------------------------------------------------------------
# map_finish_reason tests
# ---------------------------------------------------------------------------
VALID_OPENAI_FINISH_REASONS = {"stop", "length", "tool_calls", "function_call", "content_filter"}
class TestMapFinishReasonAnthropic:
def test_stop_sequence(self):
assert map_finish_reason("stop_sequence") == "stop"
def test_end_turn(self):
assert map_finish_reason("end_turn") == "stop"
def test_max_tokens(self):
assert map_finish_reason("max_tokens") == "length"
def test_tool_use(self):
assert map_finish_reason("tool_use") == "tool_calls"
def test_compaction(self):
assert map_finish_reason("compaction") == "length"
class TestMapFinishReasonGemini:
@pytest.mark.parametrize(
"gemini_reason,expected",
[
("STOP", "stop"),
("MAX_TOKENS", "length"),
("SAFETY", "content_filter"),
("RECITATION", "content_filter"),
("FINISH_REASON_UNSPECIFIED", "stop"),
("MALFORMED_FUNCTION_CALL", "stop"),
("LANGUAGE", "content_filter"),
("OTHER", "content_filter"),
("BLOCKLIST", "content_filter"),
("PROHIBITED_CONTENT", "content_filter"),
("SPII", "content_filter"),
("IMAGE_SAFETY", "content_filter"),
("IMAGE_PROHIBITED_CONTENT", "content_filter"),
("TOO_MANY_TOOL_CALLS", "stop"),
("MALFORMED_RESPONSE", "stop"),
],
)
def test_gemini_finish_reasons(self, gemini_reason, expected):
assert map_finish_reason(gemini_reason) == expected
class TestMapFinishReasonCohere:
def test_complete(self):
assert map_finish_reason("COMPLETE") == "stop"
def test_error_toxic(self):
assert map_finish_reason("ERROR_TOXIC") == "content_filter"
def test_error(self):
assert map_finish_reason("ERROR") == "stop"
class TestMapFinishReasonHuggingFace:
def test_eos_token(self):
assert map_finish_reason("eos_token") == "stop"
def test_eos(self):
assert map_finish_reason("eos") == "stop"
class TestMapFinishReasonBedrock:
def test_guardrail_intervened(self):
assert map_finish_reason("guardrail_intervened") == "content_filter"
class TestMapFinishReasonOpenAIPassthrough:
@pytest.mark.parametrize(
"reason", ["stop", "length", "tool_calls", "function_call", "content_filter"]
)
def test_openai_values_pass_through(self, reason):
assert map_finish_reason(reason) == reason
class TestMapFinishReasonUnknown:
def test_unknown_value_defaults_to_stop(self):
assert map_finish_reason("some_unknown_value") == "stop"
def test_empty_string_defaults_to_stop(self):
assert map_finish_reason("") == "stop"
class TestFinishReasonMapOutputsAreValid:
def test_all_mapped_values_are_valid_openai_reasons(self):
"""Every value in _FINISH_REASON_MAP must be a valid OpenAI finish reason."""
for provider_reason, openai_reason in _FINISH_REASON_MAP.items():
assert openai_reason in VALID_OPENAI_FINISH_REASONS, (
f"Mapped value '{openai_reason}' (from '{provider_reason}') "
f"is not a valid OpenAI finish reason"
)
@@ -192,23 +192,6 @@ def test_azure_gpt5_1_series_temperature_handling(config: AzureOpenAIGPT5Config)
assert params["temperature"] == 0.6
def test_azure_gpt5_4_drops_reasoning_effort_when_tools_present(config: AzureOpenAIGPT5Config):
"""Azure Chat Completions: gpt-5.4+ drops reasoning_effort when tools are present.
OpenAI routes tools+reasoning to Responses API; Azure does not, so we drop reasoning_effort.
"""
tools = [{"type": "function", "function": {"name": "test", "description": "test"}}]
params = config.map_openai_params(
non_default_params={"reasoning_effort": "high", "tools": tools},
optional_params={},
model="gpt5_series/gpt-5.4",
drop_params=False,
api_version="2024-05-01-preview",
)
assert "reasoning_effort" not in params
assert params["tools"] == tools
def test_azure_gpt5_reasoning_effort_none_error(config: AzureOpenAIGPT5Config):
"""Test that Azure GPT-5 (non-5.1) raises error for reasoning_effort='none' when drop_params=False."""
with pytest.raises(litellm.utils.UnsupportedParamsError):
@@ -43,29 +43,6 @@ def test_transform_usage():
)
assert openai_usage._cache_creation_input_tokens == usage["cacheWriteInputTokens"]
assert openai_usage._cache_read_input_tokens == usage["cacheReadInputTokens"]
# completion_tokens_details should always be populated
assert openai_usage.completion_tokens_details is not None
assert openai_usage.completion_tokens_details.reasoning_tokens == 0
assert openai_usage.completion_tokens_details.text_tokens == usage["outputTokens"]
def test_transform_usage_with_reasoning_content():
"""Test that completion_tokens_details correctly tracks reasoning vs text tokens."""
usage = ConverseTokenUsageBlock(
**{
"inputTokens": 10,
"outputTokens": 100,
"totalTokens": 110,
}
)
config = AmazonConverseConfig()
reasoning_text = "Let me think about this step by step."
openai_usage = config._transform_usage(usage, reasoning_content=reasoning_text)
assert openai_usage.completion_tokens_details is not None
assert openai_usage.completion_tokens_details.reasoning_tokens > 0
assert openai_usage.completion_tokens_details.text_tokens == (
usage["outputTokens"] - openai_usage.completion_tokens_details.reasoning_tokens
)
def test_transform_system_message():
@@ -3193,33 +3170,6 @@ def test_transform_request_with_output_config():
assert result["outputConfig"]["textFormat"]["structure"]["jsonSchema"]["name"] == "TestSchema"
def test_output_config_snake_case_stripped_from_bedrock_converse_request():
"""Test that output_config (snake_case) is stripped from Bedrock Converse requests.
Bedrock Converse API doesn't support the output_config parameter (Anthropic-only).
Nova and other Converse models reject requests with extraneous output_config.
"""
config = AmazonConverseConfig()
messages = [{"role": "user", "content": "test"}]
optional_params = {
"output_config": {"effort": "high"},
}
result = config._transform_request(
model="us.amazon.nova-pro-v1:0",
messages=messages,
optional_params=optional_params,
litellm_params={},
headers={},
)
# output_config must not appear in additionalModelRequestFields
additional = result.get("additionalModelRequestFields", {})
assert "output_config" not in additional, (
f"output_config should be stripped for Bedrock Converse, got: {list(additional.keys())}"
)
def test_transform_response_native_structured_output():
"""Test response handling when model returns JSON as text content (native structured output)."""
response_json = {
@@ -110,60 +110,6 @@ def test_get_supported_openai_params_reasoning_effort():
assert "reasoning_effort" not in unsupported_params
@pytest.mark.parametrize(
"api_base, expected_url_prefix",
[
(
"https://api.fireworks.ai/inference/v1",
"https://api.fireworks.ai/inference/v1/accounts/",
),
(
"https://api.fireworks.ai/inference/v1/",
"https://api.fireworks.ai/inference/v1/accounts/",
),
(
"https://custom-host.example.com/v1",
"https://custom-host.example.com/v1/accounts/",
),
(
"https://custom-host.example.com/api",
"https://custom-host.example.com/api/v1/accounts/",
),
],
ids=["default", "trailing-slash", "custom-with-v1", "custom-without-v1"],
)
def test_get_models_url_no_double_v1(api_base, expected_url_prefix):
"""Ensure get_models never produces a /v1/v1/ URL segment (fixes #23106)."""
config = FireworksAIConfig()
account_id = "fireworks"
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"models": [{"name": "accounts/fireworks/models/llama-v3-70b"}]
}
with (
patch("litellm.module_level_client.get", return_value=mock_response) as mock_get,
patch(
"litellm.llms.fireworks_ai.chat.transformation.get_secret_str",
side_effect=lambda key: {
"FIREWORKS_API_KEY": "test-key",
"FIREWORKS_API_BASE": api_base,
"FIREWORKS_ACCOUNT_ID": account_id,
}.get(key),
),
):
result = config.get_models(api_key="test-key", api_base=api_base)
called_url = mock_get.call_args.kwargs.get("url") or mock_get.call_args[1].get("url", "")
assert "/v1/v1/" not in called_url, f"Double /v1/ detected in URL: {called_url}"
assert called_url.startswith(expected_url_prefix), (
f"URL {called_url} does not start with {expected_url_prefix}"
)
assert result == ["fireworks_ai/accounts/fireworks/models/llama-v3-70b"]
def test_transform_messages_helper_removes_provider_specific_fields():
"""
Test that _transform_messages_helper removes provider_specific_fields from messages.
@@ -13,7 +13,6 @@ from litellm.llms.openai.chat.gpt_transformation import (
OpenAIChatCompletionStreamingHandler,
OpenAIGPTConfig,
)
from litellm.llms.openai.chat.gpt_5_transformation import OpenAIGPT5Config
class TestOpenAIGPTConfig:
@@ -325,195 +324,3 @@ class TestPromptCacheParams:
)
assert optional_params.get("prompt_cache_key") == "my-cache-key"
assert optional_params.get("prompt_cache_retention") == "24h"
class TestGPT5ReasoningEffortPreservation:
"""Tests for GPT-5 reasoning_effort dict preservation for Responses API."""
def setup_method(self):
self.config = OpenAIGPT5Config()
def test_reasoning_effort_string_preserved(self):
"""Test that reasoning_effort as string is preserved."""
non_default_params = {"reasoning_effort": "high"}
optional_params = {}
self.config.map_openai_params(
non_default_params=non_default_params,
optional_params=optional_params,
model="gpt-5.4",
drop_params=False,
)
# String format should be preserved
assert non_default_params.get("reasoning_effort") == "high"
def test_reasoning_effort_dict_with_only_effort_normalized(self):
"""Test that reasoning_effort dict with only 'effort' key is normalized to string."""
non_default_params = {"reasoning_effort": {"effort": "high"}}
optional_params = {}
self.config.map_openai_params(
non_default_params=non_default_params,
optional_params=optional_params,
model="gpt-5.4",
drop_params=False,
)
# Dict with only 'effort' should be normalized to string
assert non_default_params.get("reasoning_effort") == "high"
def test_reasoning_effort_dict_with_summary_preserved(self):
"""Test that reasoning_effort dict with 'summary' field is preserved for Responses API.
Regression test for: User reported that summary field was being dropped when
routing to Responses API. The dict format with additional fields should be
preserved so it can be properly handled by the Responses API transformation.
"""
non_default_params = {"reasoning_effort": {"effort": "high", "summary": "detailed"}}
optional_params = {}
self.config.map_openai_params(
non_default_params=non_default_params,
optional_params=optional_params,
model="gpt-5.4",
drop_params=False,
)
# Dict with additional fields should be preserved
assert non_default_params.get("reasoning_effort") == {"effort": "high", "summary": "detailed"}
assert isinstance(non_default_params.get("reasoning_effort"), dict)
assert non_default_params["reasoning_effort"]["effort"] == "high"
assert non_default_params["reasoning_effort"]["summary"] == "detailed"
def test_reasoning_effort_dict_with_generate_summary_preserved(self):
"""Test that reasoning_effort dict with 'generate_summary' field is preserved."""
non_default_params = {"reasoning_effort": {"effort": "medium", "generate_summary": "auto"}}
optional_params = {}
self.config.map_openai_params(
non_default_params=non_default_params,
optional_params=optional_params,
model="gpt-5.4",
drop_params=False,
)
# Dict with additional fields should be preserved
assert non_default_params.get("reasoning_effort") == {"effort": "medium", "generate_summary": "auto"}
assert isinstance(non_default_params.get("reasoning_effort"), dict)
def test_reasoning_effort_dict_with_all_fields_preserved(self):
"""Test that reasoning_effort dict with all fields is preserved."""
non_default_params = {
"reasoning_effort": {
"effort": "high",
"summary": "detailed",
"generate_summary": "concise"
}
}
optional_params = {}
self.config.map_openai_params(
non_default_params=non_default_params,
optional_params=optional_params,
model="gpt-5.4",
drop_params=False,
)
# Dict with all fields should be preserved
reasoning = non_default_params.get("reasoning_effort")
assert isinstance(reasoning, dict)
assert reasoning["effort"] == "high"
assert reasoning["summary"] == "detailed"
assert reasoning["generate_summary"] == "concise"
def test_reasoning_effort_dict_xhigh_triggers_validation(self):
"""xhigh-dict: effective effort is extracted for model-support validation.
When reasoning_effort={"effort": "xhigh", "summary": "detailed"} is passed to a model
that doesn't support xhigh (e.g. gpt-5.1), the xhigh guard must fire.
"""
import litellm
non_default_params = {"reasoning_effort": {"effort": "xhigh", "summary": "detailed"}}
optional_params = {}
with pytest.raises(litellm.utils.UnsupportedParamsError):
self.config.map_openai_params(
non_default_params=non_default_params,
optional_params=optional_params,
model="gpt-5.1",
drop_params=False,
)
def test_reasoning_effort_dict_xhigh_dropped_when_requested(self):
"""xhigh-dict with drop_params=True: reasoning_effort is dropped."""
non_default_params = {"reasoning_effort": {"effort": "xhigh", "summary": "detailed"}}
optional_params = {}
self.config.map_openai_params(
non_default_params=non_default_params,
optional_params=optional_params,
model="gpt-5.1",
drop_params=True,
)
assert "reasoning_effort" not in non_default_params
def test_reasoning_effort_dict_none_treated_as_none_for_tools(self):
"""none-dict: {"effort": "none", "summary": "detailed"} is treated as effort=none.
Tool-drop guard should NOT fire; reasoning_effort should be kept.
"""
tools = [{"type": "function", "function": {"name": "test", "description": "test"}}]
non_default_params = {"reasoning_effort": {"effort": "none", "summary": "detailed"}, "tools": tools}
optional_params = {}
self.config.map_openai_params(
non_default_params=non_default_params,
optional_params=optional_params,
model="gpt-5.4",
drop_params=False,
)
assert non_default_params.get("reasoning_effort") == {"effort": "none", "summary": "detailed"}
assert non_default_params.get("tools") == tools
def test_reasoning_effort_dict_none_treated_as_none_for_sampling(self):
"""none-dict: {"effort": "none", "summary": "detailed"} allows logprobs/top_p.
Sampling-param guard should NOT fire; logprobs should be kept.
"""
non_default_params = {
"reasoning_effort": {"effort": "none", "summary": "detailed"},
"logprobs": True,
}
optional_params = {}
self.config.map_openai_params(
non_default_params=non_default_params,
optional_params=optional_params,
model="gpt-5.1",
drop_params=False,
)
assert non_default_params.get("reasoning_effort") == {"effort": "none", "summary": "detailed"}
assert non_default_params.get("logprobs") is True
def test_reasoning_effort_dict_none_allows_temperature(self):
"""none-dict: {"effort": "none", "summary": "detailed"} allows non-default temperature."""
non_default_params = {
"reasoning_effort": {"effort": "none", "summary": "detailed"},
"temperature": 0.5,
}
optional_params = {}
self.config.map_openai_params(
non_default_params=non_default_params,
optional_params=optional_params,
model="gpt-5.1",
drop_params=False,
)
assert optional_params.get("temperature") == 0.5
assert non_default_params.get("reasoning_effort") == {"effort": "none", "summary": "detailed"}
@@ -324,11 +324,10 @@ def test_gpt5_4_pro_allows_reasoning_effort_xhigh(config: OpenAIConfig):
assert params["reasoning_effort"] == "xhigh"
def test_gpt5_preserves_reasoning_effort_dict_with_summary(config: OpenAIConfig):
"""Dict with summary/generate_summary is preserved for Responses API.
def test_gpt5_normalizes_reasoning_effort_dict_to_string(config: OpenAIConfig):
"""Chat completion API expects reasoning_effort as a string, not a dict.
Config/deployments may pass Responses API format: {'effort': 'high', 'summary': 'detailed'}.
We preserve the full dict so it reaches the Responses API transformation.
"""
params = config.map_openai_params(
non_default_params={"reasoning_effort": {"effort": "high", "summary": "detailed"}},
@@ -336,82 +335,18 @@ def test_gpt5_preserves_reasoning_effort_dict_with_summary(config: OpenAIConfig)
model="gpt-5.4",
drop_params=False,
)
assert params["reasoning_effort"] == {"effort": "high", "summary": "detailed"}
assert params["reasoning_effort"] == "high"
def test_gpt5_xhigh_dict_triggers_validation(config: OpenAIConfig):
"""Dict with effort='xhigh' triggers xhigh model-support validation.
Regression: when reasoning_effort is a dict, effective_effort must be used for
the xhigh guard so validation is not silently skipped.
"""
with pytest.raises(litellm.utils.UnsupportedParamsError):
config.map_openai_params(
non_default_params={"reasoning_effort": {"effort": "xhigh", "summary": "detailed"}},
optional_params={},
model="gpt-5.1",
drop_params=False,
)
def test_gpt5_xhigh_dict_accepted_for_supported_model(config: OpenAIConfig):
"""Dict with effort='xhigh' passes through for gpt-5.4+."""
params = config.map_openai_params(
non_default_params={"reasoning_effort": {"effort": "xhigh", "summary": "detailed"}},
optional_params={},
model="gpt-5.4",
drop_params=False,
)
assert params["reasoning_effort"] == {"effort": "xhigh", "summary": "detailed"}
def test_gpt5_none_dict_with_tools_no_tool_drop(config: OpenAIConfig):
"""Dict with effort='none' and tools: no tool-drop, reasoning_effort preserved.
Regression: effective_effort='none' must be used for tool-drop guard so
{"effort": "none", "summary": "detailed"} is not incorrectly treated as non-none.
"""
tools = [{"type": "function", "function": {"name": "test", "description": "test"}}]
params = config.map_openai_params(
non_default_params={"reasoning_effort": {"effort": "none", "summary": "detailed"}, "tools": tools},
optional_params={},
model="gpt-5.4",
drop_params=False,
)
assert params["reasoning_effort"] == {"effort": "none", "summary": "detailed"}
assert params["tools"] == tools
def test_gpt5_none_dict_with_sampling_params_allowed(config: OpenAIConfig):
"""Dict with effort='none' allows logprobs/top_p/top_logprobs.
Regression: effective_effort='none' must be used for sampling guard so
{"effort": "none", "summary": "detailed"} does not incorrectly trigger sampling errors.
"""
params = config.map_openai_params(
non_default_params={
"reasoning_effort": {"effort": "none", "summary": "detailed"},
"logprobs": True,
"top_p": 0.9,
},
optional_params={},
model="gpt-5.1",
drop_params=False,
)
assert params["reasoning_effort"] == {"effort": "none", "summary": "detailed"}
assert params["logprobs"] is True
assert params["top_p"] == 0.9
def test_gpt5_preserves_reasoning_effort_dict_with_summary_from_optional_params(config: OpenAIConfig):
"""reasoning_effort dict with summary in optional_params is preserved."""
def test_gpt5_normalizes_reasoning_effort_dict_from_optional_params(config: OpenAIConfig):
"""reasoning_effort dict in optional_params (e.g. from model config) is normalized."""
params = config.map_openai_params(
non_default_params={},
optional_params={"reasoning_effort": {"effort": "medium", "summary": "detailed"}},
model="gpt-5.4",
drop_params=False,
)
assert params["reasoning_effort"] == {"effort": "medium", "summary": "detailed"}
assert params["reasoning_effort"] == "medium"
def test_gpt5_4_drops_reasoning_effort_when_tools_present(config: OpenAIConfig):
@@ -4,6 +4,7 @@ from typing import Dict
import pytest
from litellm import image_edit
from litellm.images.utils import ImageEditRequestUtils
from litellm.llms.openai.image_edit.transformation import OpenAIImageEditConfig
from litellm.types.router import GenericLiteLLMParams
@@ -254,3 +255,50 @@ def test_transform_image_edit_request_with_mask_list(image_edit_config: OpenAIIm
mask_file = next(f for f in files if f[0] == "mask")
assert mask_file[1][1] == mask1 # Should be the first mask, not the second
def test_transform_image_edit_request_with_input_fidelity(
image_edit_config: OpenAIImageEditConfig,
):
"""Test that input_fidelity is included in the data dict when provided"""
model = "gpt-image-1"
prompt = "Make the background blue"
image = b"fake_image_data"
image_edit_optional_request_params = {"input_fidelity": "high"}
litellm_params = GenericLiteLLMParams()
headers = {}
data, files = image_edit_config.transform_image_edit_request(
model=model,
prompt=prompt,
image=image,
image_edit_optional_request_params=image_edit_optional_request_params,
litellm_params=litellm_params,
headers=headers,
)
assert data["input_fidelity"] == "high"
assert data["model"] == model
assert data["prompt"] == prompt
assert "image" not in data
def test_get_supported_openai_params_includes_input_fidelity(
image_edit_config: OpenAIImageEditConfig,
):
"""Test that input_fidelity is in the supported params list"""
supported = image_edit_config.get_supported_openai_params(model="gpt-image-1")
assert "input_fidelity" in supported
def test_input_fidelity_passes_through_optional_param_filter():
"""Test that input_fidelity is not dropped by get_requested_image_edit_optional_param"""
params = {
"input_fidelity": "low",
"quality": "high",
"unknown_param": "should_be_dropped",
}
filtered = ImageEditRequestUtils.get_requested_image_edit_optional_param(params)
assert filtered["input_fidelity"] == "low"
assert filtered["quality"] == "high"
assert "unknown_param" not in filtered
@@ -0,0 +1,341 @@
"""
Tests for OpenAI-like Responses API support in the JSON provider system.
"""
import os
import sys
from unittest.mock import patch
import pytest
sys.path.insert(
0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../.."))
)
class TestSimpleProviderConfigSupportedEndpoints:
"""Test the supported_endpoints field on SimpleProviderConfig."""
def test_default_supported_endpoints(self):
"""supported_endpoints defaults to [] (chat always enabled, nothing else)"""
from litellm.llms.openai_like.json_loader import SimpleProviderConfig
config = SimpleProviderConfig("test", {"base_url": "https://example.com", "api_key_env": "TEST_KEY"})
assert config.supported_endpoints == []
def test_custom_supported_endpoints(self):
"""supported_endpoints can be set explicitly"""
from litellm.llms.openai_like.json_loader import SimpleProviderConfig
config = SimpleProviderConfig(
"test",
{
"base_url": "https://example.com",
"api_key_env": "TEST_KEY",
"supported_endpoints": ["/v1/chat/completions", "/v1/responses"],
},
)
assert "/v1/responses" in config.supported_endpoints
assert "/v1/chat/completions" in config.supported_endpoints
def test_responses_only_endpoint(self):
"""A provider can support only responses"""
from litellm.llms.openai_like.json_loader import SimpleProviderConfig
config = SimpleProviderConfig(
"test",
{
"base_url": "https://example.com",
"api_key_env": "TEST_KEY",
"supported_endpoints": ["/v1/responses"],
},
)
assert config.supported_endpoints == ["/v1/responses"]
class TestJSONProviderRegistryResponsesAPI:
"""Test supports_responses_api on JSONProviderRegistry."""
def test_existing_provider_no_responses(self):
"""Existing providers without supported_endpoints don't support responses"""
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
# publicai has no supported_endpoints in JSON, defaults to []
assert JSONProviderRegistry.supports_responses_api("publicai") is False
def test_nonexistent_provider(self):
"""Non-existent provider returns False"""
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
assert JSONProviderRegistry.supports_responses_api("nonexistent_provider_xyz") is False
def test_provider_with_responses_endpoint(self):
"""A provider with /v1/responses in supported_endpoints returns True"""
from litellm.llms.openai_like.json_loader import (
JSONProviderRegistry,
SimpleProviderConfig,
)
# Temporarily inject a test provider
test_config = SimpleProviderConfig(
"test_responses_provider",
{
"base_url": "https://test.example.com",
"api_key_env": "TEST_API_KEY",
"supported_endpoints": ["/v1/chat/completions", "/v1/responses"],
},
)
JSONProviderRegistry._providers["test_responses_provider"] = test_config
try:
assert JSONProviderRegistry.supports_responses_api("test_responses_provider") is True
finally:
del JSONProviderRegistry._providers["test_responses_provider"]
class TestCreateResponsesConfigClass:
"""Test dynamic responses config class generation."""
def _make_test_provider(self):
from litellm.llms.openai_like.json_loader import SimpleProviderConfig
return SimpleProviderConfig(
"test_resp",
{
"base_url": "https://api.testresp.com/v1",
"api_key_env": "TEST_RESP_API_KEY",
"api_base_env": "TEST_RESP_API_BASE",
"supported_endpoints": ["/v1/responses"],
},
)
def test_generated_class_custom_llm_provider(self):
"""Generated class returns the provider slug"""
from litellm.llms.openai_like.dynamic_config import (
create_responses_config_class,
)
provider = self._make_test_provider()
config_cls = create_responses_config_class(provider)
config = config_cls()
assert config.custom_llm_provider == "test_resp"
def test_generated_class_get_complete_url(self):
"""Generated class builds correct responses URL"""
from litellm.llms.openai_like.dynamic_config import (
create_responses_config_class,
)
provider = self._make_test_provider()
config_cls = create_responses_config_class(provider)
config = config_cls()
url = config.get_complete_url(api_base=None, litellm_params={})
assert url == "https://api.testresp.com/v1/responses"
def test_generated_class_get_complete_url_with_override(self):
"""api_base override takes precedence"""
from litellm.llms.openai_like.dynamic_config import (
create_responses_config_class,
)
provider = self._make_test_provider()
config_cls = create_responses_config_class(provider)
config = config_cls()
url = config.get_complete_url(api_base="https://custom.api.com/v1", litellm_params={})
assert url == "https://custom.api.com/v1/responses"
def test_generated_class_get_complete_url_strips_trailing_slash(self):
"""Trailing slashes are stripped from base URL"""
from litellm.llms.openai_like.dynamic_config import (
create_responses_config_class,
)
provider = self._make_test_provider()
config_cls = create_responses_config_class(provider)
config = config_cls()
url = config.get_complete_url(api_base="https://custom.api.com/v1/", litellm_params={})
assert url == "https://custom.api.com/v1/responses"
def test_generated_class_validate_environment(self):
"""validate_environment sets Authorization header from env"""
from litellm.llms.openai_like.dynamic_config import (
create_responses_config_class,
)
provider = self._make_test_provider()
config_cls = create_responses_config_class(provider)
config = config_cls()
with patch(
"litellm.llms.openai_like.dynamic_config.get_secret_str",
return_value="sk-test-key-123",
):
headers = config.validate_environment(headers={}, model="test-model", litellm_params=None)
assert headers["Authorization"] == "Bearer sk-test-key-123"
def test_generated_class_validate_environment_litellm_params_override(self):
"""api_key from litellm_params takes precedence over env"""
from litellm.llms.openai_like.dynamic_config import (
create_responses_config_class,
)
from litellm.types.router import GenericLiteLLMParams
provider = self._make_test_provider()
config_cls = create_responses_config_class(provider)
config = config_cls()
litellm_params = GenericLiteLLMParams(api_key="sk-override-key")
headers = config.validate_environment(
headers={}, model="test-model", litellm_params=litellm_params
)
assert headers["Authorization"] == "Bearer sk-override-key"
def test_generated_class_inherits_openai_responses_methods(self):
"""Generated class inherits OpenAI Responses API transformation methods"""
from litellm.llms.openai.responses.transformation import (
OpenAIResponsesAPIConfig,
)
from litellm.llms.openai_like.dynamic_config import (
create_responses_config_class,
)
provider = self._make_test_provider()
config_cls = create_responses_config_class(provider)
config = config_cls()
# Should have inherited methods from OpenAIResponsesAPIConfig
assert hasattr(config, "get_supported_openai_params")
assert hasattr(config, "map_openai_params")
assert hasattr(config, "transform_responses_api_request")
assert hasattr(config, "transform_response_api_response")
assert hasattr(config, "transform_streaming_response")
# Verify inheritance chain
assert isinstance(config, OpenAIResponsesAPIConfig)
def test_generated_class_get_complete_url_uses_api_base_env(self):
"""get_complete_url falls back to api_base_env when api_base is None"""
from litellm.llms.openai_like.dynamic_config import (
create_responses_config_class,
)
provider = self._make_test_provider()
config_cls = create_responses_config_class(provider)
config = config_cls()
with patch(
"litellm.llms.openai_like.dynamic_config.get_secret_str",
return_value="https://env-override.example.com/v1",
):
url = config.get_complete_url(api_base=None, litellm_params={})
assert url == "https://env-override.example.com/v1/responses"
class TestProviderConfigManagerResponsesAPI:
"""Test that ProviderConfigManager integrates JSON responses providers."""
def test_json_provider_with_responses_returns_config(self):
"""A JSON provider with /v1/responses returns a responses config"""
from litellm.llms.openai_like.json_loader import (
JSONProviderRegistry,
SimpleProviderConfig,
)
from litellm.utils import ProviderConfigManager
test_config = SimpleProviderConfig(
"test_pcm_resp",
{
"base_url": "https://api.testpcm.com/v1",
"api_key_env": "TEST_PCM_KEY",
"supported_endpoints": ["/v1/responses"],
},
)
JSONProviderRegistry._providers["test_pcm_resp"] = test_config
try:
config = ProviderConfigManager.get_provider_responses_api_config(
provider="test_pcm_resp",
model="some-model",
)
assert config is not None
assert config.custom_llm_provider == "test_pcm_resp"
finally:
del JSONProviderRegistry._providers["test_pcm_resp"]
def test_json_provider_without_responses_returns_none(self):
"""A JSON provider without /v1/responses returns None"""
from litellm.utils import ProviderConfigManager
# publicai only supports chat completions
config = ProviderConfigManager.get_provider_responses_api_config(
provider="publicai",
model="some-model",
)
assert config is None
def test_unknown_provider_returns_none(self):
"""A completely unknown provider returns None"""
from litellm.utils import ProviderConfigManager
config = ProviderConfigManager.get_provider_responses_api_config(
provider="totally_unknown_provider_xyz",
model="some-model",
)
assert config is None
def test_standard_providers_still_work(self):
"""Existing enum-based providers still resolve correctly"""
from litellm.types.utils import LlmProviders
from litellm.utils import ProviderConfigManager
config = ProviderConfigManager.get_provider_responses_api_config(
provider=LlmProviders.OPENAI,
model="gpt-4o",
)
assert config is not None
def test_standard_provider_as_string_still_works(self):
"""Passing 'openai' as a string also works"""
from litellm.utils import ProviderConfigManager
config = ProviderConfigManager.get_provider_responses_api_config(
provider="openai",
model="gpt-4o",
)
assert config is not None
def test_python_class_takes_priority_over_json(self):
"""If a provider has both a Python class and JSON config, Python wins"""
from litellm.llms.openai_like.json_loader import (
JSONProviderRegistry,
SimpleProviderConfig,
)
from litellm.llms.perplexity.responses.transformation import (
PerplexityResponsesConfig,
)
from litellm.utils import ProviderConfigManager
# Inject perplexity into JSON registry with responses support
test_config = SimpleProviderConfig(
"perplexity",
{
"base_url": "https://api.perplexity.ai",
"api_key_env": "PERPLEXITY_API_KEY",
"supported_endpoints": ["/v1/responses"],
},
)
original = JSONProviderRegistry._providers.get("perplexity")
JSONProviderRegistry._providers["perplexity"] = test_config
try:
config = ProviderConfigManager.get_provider_responses_api_config(
provider="perplexity",
model="some-model",
)
# Should be the Python class, not the JSON-generated one
assert isinstance(config, PerplexityResponsesConfig)
finally:
if original is not None:
JSONProviderRegistry._providers["perplexity"] = original
else:
del JSONProviderRegistry._providers["perplexity"]
@@ -7,11 +7,17 @@ transformations for the Agent API (Responses API).
Source: litellm/llms/perplexity/responses/transformation.py
"""
import json
import os
import sys
import httpx
import pytest
sys.path.insert(0, os.path.abspath("../../../../.."))
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.perplexity.responses.transformation import PerplexityResponsesConfig
from litellm.types.llms.openai import ResponsesAPIOptionalRequestParams
from litellm.types.utils import LlmProviders
@@ -260,10 +266,12 @@ class TestPerplexityResponsesTransformation:
assert result.get("user") == "user_456"
def test_all_supported_params_declared(self):
"""get_supported_openai_params returns complete list"""
"""get_supported_openai_params returns Perplexity-specific restricted list"""
config = PerplexityResponsesConfig()
supported = config.get_supported_openai_params("perplexity/openai/gpt-5.2")
# Perplexity Responses API supports a restricted set of params
# Ref: https://docs.perplexity.ai/api-reference/responses-post
expected = [
"max_output_tokens",
"stream",
@@ -271,68 +279,46 @@ class TestPerplexityResponsesTransformation:
"top_p",
"tools",
"reasoning",
"preset",
"instructions",
"models",
"tool_choice",
"parallel_tool_calls",
"max_tool_calls",
"text",
"previous_response_id",
"store",
"background",
"truncation",
"metadata",
"safety_identifier",
"user",
"stream_options",
"top_logprobs",
"prompt_cache_key",
"frequency_penalty",
"presence_penalty",
"service_tier",
]
for param in expected:
assert param in supported, f"Missing supported param: {param}"
def test_cost_transformation(self):
"""Perplexity cost dict to OpenAI float"""
config = PerplexityResponsesConfig()
def test_cost_dict_to_float_via_validator(self):
"""Perplexity cost dict is parsed by generic ResponseAPIUsage.parse_cost validator"""
from litellm.types.llms.openai import ResponseAPIUsage
usage_data = {
"input_tokens": 100,
"output_tokens": 200,
"total_tokens": 300,
"cost": {
usage = ResponseAPIUsage(
input_tokens=100,
output_tokens=200,
total_tokens=300,
cost={
"currency": "USD",
"input_cost": 0.0001,
"output_cost": 0.0002,
"total_cost": 0.0003,
},
}
)
result = config._transform_usage(usage_data)
assert usage.input_tokens == 100
assert usage.output_tokens == 200
assert usage.total_tokens == 300
assert usage.cost == 0.0003
assert result["input_tokens"] == 100
assert result["output_tokens"] == 200
assert result["total_tokens"] == 300
assert result["cost"] == 0.0003
def test_cost_float_passthrough_via_validator(self):
"""Cost already float passes through validator unchanged"""
from litellm.types.llms.openai import ResponseAPIUsage
def test_cost_transformation_float_passthrough(self):
"""Cost already float passes through"""
config = PerplexityResponsesConfig()
usage = ResponseAPIUsage(
input_tokens=100,
output_tokens=200,
total_tokens=300,
cost=0.0005,
)
usage_data = {
"input_tokens": 100,
"output_tokens": 200,
"total_tokens": 300,
"cost": 0.0005,
}
result = config._transform_usage(usage_data)
assert result["cost"] == 0.0005
assert usage.cost == 0.0005
def test_preset_handling(self):
"""Preset model names work"""
@@ -350,6 +336,85 @@ class TestPerplexityResponsesTransformation:
assert data["input"] == "What is AI?"
assert "temperature" in data
def test_preset_handling_list_input(self):
"""Preset with list input preserves type field"""
config = PerplexityResponsesConfig()
list_input = [
{"type": "message", "role": "user", "content": "What is AI?"},
]
data = config.transform_responses_api_request(
model="preset/pro-search",
input=list_input,
response_api_optional_request_params={"temperature": 0.7},
litellm_params={},
headers={},
)
assert data["preset"] == "pro-search"
assert isinstance(data["input"], list)
assert data["input"][0]["type"] == "message"
assert data["input"][0]["role"] == "user"
def test_non_preset_list_input(self):
"""Non-preset with list input preserves type field"""
config = PerplexityResponsesConfig()
list_input = [
{"type": "message", "role": "user", "content": "Hello"},
]
data = config.transform_responses_api_request(
model="openai/gpt-5.2",
input=list_input,
response_api_optional_request_params={},
litellm_params={},
headers={},
)
assert data["model"] == "openai/gpt-5.2"
assert isinstance(data["input"], list)
assert data["input"][0]["type"] == "message"
def test_list_input_adds_type_message_when_missing(self):
"""Input items without type get type='message' added automatically"""
config = PerplexityResponsesConfig()
list_input = [
{"role": "user", "content": "Hello"},
]
data = config.transform_responses_api_request(
model="openai/gpt-5.2",
input=list_input,
response_api_optional_request_params={},
litellm_params={},
headers={},
)
assert data["input"][0]["type"] == "message"
assert data["input"][0]["role"] == "user"
assert data["input"][0]["content"] == "Hello"
def test_list_input_preserves_existing_type(self):
"""Input items that already have type are not modified"""
config = PerplexityResponsesConfig()
list_input = [
{"type": "function_call_output", "call_id": "123", "output": "{}"},
]
data = config.transform_responses_api_request(
model="openai/gpt-5.2",
input=list_input,
response_api_optional_request_params={},
litellm_params={},
headers={},
)
assert data["input"][0]["type"] == "function_call_output"
def test_get_complete_url(self):
"""Correct endpoint URL"""
config = PerplexityResponsesConfig()
@@ -379,3 +444,149 @@ class TestPerplexityResponsesTransformation:
assert config is not None
assert isinstance(config, PerplexityResponsesConfig)
assert config.custom_llm_provider == LlmProviders.PERPLEXITY
def test_failed_status_raises_exception(self):
"""Perplexity HTTP 200 with status:'failed' must raise BaseLLMException"""
config = PerplexityResponsesConfig()
failed_body = {
"status": "failed",
"error": {"message": "Model quota exceeded"},
}
raw_response = httpx.Response(
status_code=200,
json=failed_body,
request=httpx.Request("POST", "https://api.perplexity.ai/v1/responses"),
)
logging_obj = LiteLLMLoggingObj(
model="perplexity/openai/gpt-5.2",
messages=[],
stream=False,
call_type="responses",
start_time=None,
litellm_call_id="test",
function_id="test",
)
with pytest.raises(BaseLLMException) as exc_info:
config.transform_response_api_response(
model="perplexity/openai/gpt-5.2",
raw_response=raw_response,
logging_obj=logging_obj,
)
assert "Model quota exceeded" in str(exc_info.value.message)
def test_successful_response_passes_through(self):
"""Normal completed response delegates to base OpenAI handler"""
config = PerplexityResponsesConfig()
success_body = {
"id": "resp_123",
"object": "response",
"created_at": 1700000000,
"status": "completed",
"model": "openai/gpt-5.2",
"output": [
{
"type": "message",
"id": "msg_123",
"role": "assistant",
"status": "completed",
"content": [
{"type": "output_text", "text": "Hello!", "annotations": []}
],
}
],
"usage": {
"input_tokens": 10,
"output_tokens": 5,
"total_tokens": 15,
},
}
raw_response = httpx.Response(
status_code=200,
json=success_body,
request=httpx.Request("POST", "https://api.perplexity.ai/v1/responses"),
)
logging_obj = LiteLLMLoggingObj(
model="perplexity/openai/gpt-5.2",
messages=[],
stream=False,
call_type="responses",
start_time=None,
litellm_call_id="test",
function_id="test",
)
response = config.transform_response_api_response(
model="perplexity/openai/gpt-5.2",
raw_response=raw_response,
logging_obj=logging_obj,
)
assert response.id == "resp_123"
assert response.status == "completed"
def test_streaming_cost_dict_to_float_via_validator(self):
"""Cost dict in a streaming response.completed chunk is converted to float
end-to-end through transform_streaming_response via pydantic's recursive
construction of ResponsesAPIResponse ResponseAPIUsage.parse_cost."""
config = PerplexityResponsesConfig()
completed_chunk = {
"type": "response.completed",
"response": {
"id": "resp_streaming_123",
"object": "response",
"created_at": 1700000000,
"status": "completed",
"model": "openai/gpt-5.2",
"output": [
{
"type": "message",
"id": "msg_123",
"role": "assistant",
"status": "completed",
"content": [
{"type": "output_text", "text": "Hello!", "annotations": []}
],
}
],
"usage": {
"input_tokens": 100,
"output_tokens": 200,
"total_tokens": 300,
"cost": {
"currency": "USD",
"input_cost": 0.0001,
"output_cost": 0.0002,
"total_cost": 0.0003,
},
},
},
}
logging_obj = LiteLLMLoggingObj(
model="perplexity/openai/gpt-5.2",
messages=[],
stream=True,
call_type="responses",
start_time=None,
litellm_call_id="test",
function_id="test",
)
result = config.transform_streaming_response(
model="perplexity/openai/gpt-5.2",
parsed_chunk=completed_chunk,
logging_obj=logging_obj,
)
assert result.type == "response.completed"
assert result.response.usage.cost == 0.0003
assert isinstance(result.response.usage.cost, float)
@@ -1,243 +0,0 @@
"""
Test cases for SageMaker embedding role assumption support
This module tests that the SageMaker embedding handler properly supports
AWS IAM role assumption via aws_role_name and aws_session_name parameters,
matching the behavior of the completion handler.
"""
import json
import os
import sys
from datetime import timezone
from unittest.mock import MagicMock, call, patch
sys.path.insert(0, os.path.abspath("../../../../.."))
from botocore.credentials import Credentials
from litellm.llms.sagemaker.completion.handler import SagemakerLLM
from litellm.types.utils import EmbeddingResponse
class TestSagemakerEmbeddingRoleAssumption:
"""Test that SageMaker embedding supports role assumption like completion does"""
def setup_method(self):
self.sagemaker_llm = SagemakerLLM()
def test_embedding_uses_load_credentials(self):
"""
Test that embedding() calls _load_credentials() to support role assumption.
This ensures aws_role_name and aws_session_name parameters are properly handled.
"""
# Mock credentials that would be returned after role assumption
mock_credentials = Credentials(
access_key="assumed-access-key",
secret_key="assumed-secret-key",
token="assumed-session-token",
)
# Mock the SageMaker client response
mock_sagemaker_client = MagicMock()
mock_sagemaker_client.invoke_endpoint.return_value = {
"Body": MagicMock(
read=MagicMock(return_value=json.dumps({"embedding": [[0.1, 0.2, 0.3]]}).encode())
)
}
# Mock boto3.Session to return our mock client
mock_session = MagicMock()
mock_session.client.return_value = mock_sagemaker_client
with patch.object(
self.sagemaker_llm, "_load_credentials", return_value=(mock_credentials, "us-east-1")
) as mock_load_creds, patch("boto3.Session", return_value=mock_session):
# Create mock logging object
mock_logging = MagicMock()
optional_params = {
"aws_role_name": "arn:aws:iam::123456789012:role/TestRole",
"aws_session_name": "test-session",
}
self.sagemaker_llm.embedding(
model="test-endpoint",
input=["hello world"],
model_response=EmbeddingResponse(),
print_verbose=print,
encoding=None,
logging_obj=mock_logging,
optional_params=optional_params,
)
# Verify _load_credentials was called with the optional_params
mock_load_creds.assert_called_once()
# Verify boto3.Session was created with the assumed credentials
mock_session_calls = mock_session.client.call_args_list
assert len(mock_session_calls) == 1
assert mock_session_calls[0] == call(service_name="sagemaker-runtime")
def test_embedding_role_assumption_with_sts(self):
"""
Test the full role assumption flow for embeddings, similar to completion.
Verifies that STS assume_role is called when aws_role_name is provided.
"""
# Mock the STS client for role assumption
mock_sts_client = MagicMock()
# Mock the STS response with proper expiration handling
mock_expiry = MagicMock()
mock_expiry.tzinfo = timezone.utc
time_diff = MagicMock()
time_diff.total_seconds.return_value = 3600
mock_expiry.__sub__ = MagicMock(return_value=time_diff)
mock_sts_response = {
"Credentials": {
"AccessKeyId": "assumed-access-key",
"SecretAccessKey": "assumed-secret-key",
"SessionToken": "assumed-session-token",
"Expiration": mock_expiry,
}
}
mock_sts_client.assume_role.return_value = mock_sts_response
# Mock the SageMaker client response
mock_sagemaker_client = MagicMock()
mock_sagemaker_client.invoke_endpoint.return_value = {
"Body": MagicMock(
read=MagicMock(return_value=json.dumps({"embedding": [[0.1, 0.2, 0.3]]}).encode())
)
}
# Mock boto3.Session for SageMaker client creation
mock_session = MagicMock()
mock_session.client.return_value = mock_sagemaker_client
def mock_boto3_client(service_name, **kwargs):
if service_name == "sts":
return mock_sts_client
return mock_sagemaker_client
with patch("boto3.client", side_effect=mock_boto3_client), \
patch("boto3.Session", return_value=mock_session):
mock_logging = MagicMock()
optional_params = {
"aws_role_name": "arn:aws:iam::123456789012:role/CrossAccountRole",
"aws_session_name": "litellm-embedding-session",
"aws_region_name": "us-east-1",
}
self.sagemaker_llm.embedding(
model="test-endpoint",
input=["hello world"],
model_response=EmbeddingResponse(),
print_verbose=print,
encoding=None,
logging_obj=mock_logging,
optional_params=optional_params,
)
# Verify STS assume_role was called with correct parameters
mock_sts_client.assume_role.assert_called_once()
call_args = mock_sts_client.assume_role.call_args
assert call_args[1]["RoleArn"] == "arn:aws:iam::123456789012:role/CrossAccountRole"
assert call_args[1]["RoleSessionName"] == "litellm-embedding-session"
def test_embedding_without_role_assumption(self):
"""
Test that embedding works without role assumption when aws_role_name is not provided.
Should use default credentials from environment/instance profile.
"""
# Mock the SageMaker client response
mock_sagemaker_client = MagicMock()
mock_sagemaker_client.invoke_endpoint.return_value = {
"Body": MagicMock(
read=MagicMock(return_value=json.dumps({"embedding": [[0.1, 0.2, 0.3]]}).encode())
)
}
mock_session = MagicMock()
mock_session.client.return_value = mock_sagemaker_client
# Mock credentials returned from environment
mock_credentials = Credentials(
access_key="env-access-key",
secret_key="env-secret-key",
token=None,
)
with patch.object(
self.sagemaker_llm, "_load_credentials", return_value=(mock_credentials, "us-west-2")
), patch("boto3.Session", return_value=mock_session):
mock_logging = MagicMock()
# No aws_role_name provided
optional_params = {
"aws_region_name": "us-west-2",
}
result = self.sagemaker_llm.embedding(
model="test-endpoint",
input=["hello world"],
model_response=EmbeddingResponse(),
print_verbose=print,
encoding=None,
logging_obj=mock_logging,
optional_params=optional_params,
)
# Should still work and return embeddings
assert result is not None
def test_embedding_session_created_with_assumed_credentials(self):
"""
Test that boto3.Session is created with the credentials from role assumption.
This verifies the credentials flow from _load_credentials to the SageMaker client.
"""
mock_credentials = Credentials(
access_key="assumed-key",
secret_key="assumed-secret",
token="assumed-token",
)
mock_sagemaker_client = MagicMock()
mock_sagemaker_client.invoke_endpoint.return_value = {
"Body": MagicMock(
read=MagicMock(return_value=json.dumps({"embedding": [[0.1, 0.2, 0.3]]}).encode())
)
}
with patch.object(
self.sagemaker_llm, "_load_credentials", return_value=(mock_credentials, "us-east-1")
), patch("boto3.Session") as mock_session_class:
mock_session = MagicMock()
mock_session.client.return_value = mock_sagemaker_client
mock_session_class.return_value = mock_session
mock_logging = MagicMock()
self.sagemaker_llm.embedding(
model="test-endpoint",
input=["hello world"],
model_response=EmbeddingResponse(),
print_verbose=print,
encoding=None,
logging_obj=mock_logging,
optional_params={},
)
# Verify Session was created with the assumed credentials
mock_session_class.assert_called_once_with(
aws_access_key_id="assumed-key",
aws_secret_access_key="assumed-secret",
aws_session_token="assumed-token",
region_name="us-east-1",
)
@@ -105,7 +105,10 @@ class TestSnowflakeToolTransformation:
def test_transform_request_with_string_tool_choice(self):
"""
Test that string tool_choice values pass through unchanged.
Test that string tool_choice values are transformed to Snowflake object format.
Snowflake requires tool_choice to be an object, not a string.
Ref: https://docs.snowflake.com/en/developer-guide/snowflake-rest-api/reference/cortex-inference#post--api-v2-cortex-inference-complete-req-body-schema
"""
config = SnowflakeConfig()
@@ -120,7 +123,8 @@ class TestSnowflakeToolTransformation:
headers={},
)
assert transformed_request["tool_choice"] == value
# Snowflake requires object format: {"type": "auto"} not string "auto"
assert transformed_request["tool_choice"] == {"type": value}
def test_transform_response_with_tool_calls(self):
"""
@@ -128,75 +128,6 @@ def test_vertex_ai_includes_labels():
def test_extra_body_cache_not_forwarded_to_vertex_ai():
"""
'cache' inside extra_body is a LiteLLM-internal proxy caching control.
It must NOT be forwarded to the Vertex AI request body.
Regression test for: "Invalid JSON payload received. Unknown name \"cache\": Cannot find field."
Vertex AI enforces a strict JSON schema and rejects any unknown field.
"""
messages = [{"role": "user", "content": "test"}]
optional_params = {
"extra_body": {
"cache": {"use-cache": True, "ttl": 86400}, # LiteLLM-internal
"some_vertex_param": "value", # legitimate provider extra
},
}
litellm_params = {}
result = _transform_request_body(
messages=messages,
model="gemini-2.5-pro",
optional_params=optional_params,
custom_llm_provider="vertex_ai",
litellm_params=litellm_params,
cached_content=None,
)
# 'cache' must be stripped — Vertex AI has no such field
assert "cache" not in result, (
"extra_body.cache must not be forwarded to Vertex AI. "
"Vertex AI rejects it with 400: Unknown name \"cache\": Cannot find field."
)
# Other legitimate extra_body keys should still pass through
assert "some_vertex_param" in result
assert result["some_vertex_param"] == "value"
# Core request fields must be present
assert "contents" in result
def test_extra_body_tags_not_forwarded_to_vertex_ai():
"""
'tags' inside extra_body is a LiteLLM-internal param for logging/tracking.
It must NOT be forwarded to the Vertex AI request body.
Documented in litellm_proxy.md: "Send tags by including them in the extra_body parameter"
"""
messages = [{"role": "user", "content": "test"}]
optional_params = {
"extra_body": {
"tags": ["user:alice", "env:prod"],
"custom_param": "allowed",
},
}
litellm_params = {}
result = _transform_request_body(
messages=messages,
model="gemini-2.5-pro",
optional_params=optional_params,
custom_llm_provider="vertex_ai",
litellm_params=litellm_params,
cached_content=None,
)
assert "tags" not in result
assert "custom_param" in result
assert result["custom_param"] == "allowed"
def test_metadata_to_labels_vertex_only():
"""Test that metadata->labels conversion only happens for Vertex AI"""
messages = [{"role": "user", "content": "test"}]
@@ -674,34 +674,32 @@ def test_check_finish_reason():
def test_finish_reason_unspecified_and_malformed_function_call():
"""
Test that FINISH_REASON_UNSPECIFIED and MALFORMED_FUNCTION_CALL
return their lowercase values instead of being mapped to 'stop'
since we don't have good mappings for these.
Test that FINISH_REASON_UNSPECIFIED and MALFORMED_FUNCTION_CALL
are mapped to OpenAI-compatible 'stop' finish reason.
"""
finish_reason_mappings = VertexGeminiConfig.get_finish_reason_mapping()
# Test FINISH_REASON_UNSPECIFIED returns lowercase version
assert finish_reason_mappings["FINISH_REASON_UNSPECIFIED"] == "finish_reason_unspecified"
# Test FINISH_REASON_UNSPECIFIED maps to "stop"
assert finish_reason_mappings["FINISH_REASON_UNSPECIFIED"] == "stop"
assert (
VertexGeminiConfig._check_finish_reason(
chat_completion_message=None, finish_reason="FINISH_REASON_UNSPECIFIED"
)
== "finish_reason_unspecified"
== "stop"
)
# Test MALFORMED_FUNCTION_CALL returns lowercase version
assert finish_reason_mappings["MALFORMED_FUNCTION_CALL"] == "malformed_function_call"
# Test MALFORMED_FUNCTION_CALL maps to "stop"
assert finish_reason_mappings["MALFORMED_FUNCTION_CALL"] == "stop"
assert (
VertexGeminiConfig._check_finish_reason(
chat_completion_message=None, finish_reason="MALFORMED_FUNCTION_CALL"
)
== "malformed_function_call"
== "stop"
)
# Ensure these values are in the OpenAI finish reasons constant
from litellm import OPENAI_FINISH_REASONS
assert "finish_reason_unspecified" in OPENAI_FINISH_REASONS
assert "malformed_function_call" in OPENAI_FINISH_REASONS
# Test new Gemini finish reasons
assert finish_reason_mappings["TOO_MANY_TOOL_CALLS"] == "stop"
assert finish_reason_mappings["MALFORMED_RESPONSE"] == "stop"
def test_vertex_ai_usage_metadata_response_token_count():
@@ -11,6 +11,7 @@ sys.path.insert(
) # Adds the parent directory to the system path
from litellm.llms.vertex_ai.common_utils import (
_build_vertex_schema_for_gemini_2,
_get_vertex_url,
convert_anyof_null_to_nullable,
get_vertex_location_from_url,
@@ -1402,3 +1403,93 @@ def test_add_object_type_does_not_add_type_when_anyof_present():
# Verify type was not added (anyOf handles the type)
assert "type" not in input_schema, "type should not be added when anyOf is present"
class TestBuildVertexSchemaForGemini2:
"""Tests for _build_vertex_schema_for_gemini_2 — minimal transform for Gemini 2.0+ tools."""
def test_jsonvalue_standalone_preserved(self):
"""JsonValue (bare {}) should NOT be coerced to {"type": "object"}."""
schema = {
"type": "object",
"properties": {
"name": {"type": "string"},
"value": {},
},
"required": ["name", "value"],
}
result = _build_vertex_schema_for_gemini_2(schema)
assert result["properties"]["value"] == {}
def test_optional_jsonvalue_anyof_preserved(self):
"""Optional[JsonValue] anyOf with null should be preserved, not converted to nullable."""
schema = {
"type": "object",
"properties": {
"name": {"type": "string"},
"value": {
"anyOf": [
{"type": "array", "items": {}},
{},
{"type": "null"},
]
},
},
"required": ["name"],
}
result = _build_vertex_schema_for_gemini_2(schema)
value_schema = result["properties"]["value"]
assert "anyOf" in value_schema
assert len(value_schema["anyOf"]) == 3
assert {"type": "null"} in value_schema["anyOf"]
assert {} in value_schema["anyOf"]
def test_ref_defs_resolved(self):
"""$ref/$defs should be resolved since Gemini doesn't support them in tool params."""
schema = {
"type": "object",
"properties": {
"value": {"$ref": "#/$defs/JsonValue"},
},
"$defs": {"JsonValue": {}},
}
result = _build_vertex_schema_for_gemini_2(schema)
assert "$ref" not in result["properties"]["value"]
assert "$defs" not in result
assert result["properties"]["value"] == {}
def test_unsupported_fields_stripped(self):
"""Fields not in Vertex Schema TypedDict should be removed."""
schema = {
"type": "object",
"properties": {
"name": {"type": "string", "additionalProperties": False},
},
"additionalProperties": False,
"$schema": "http://json-schema.org/draft-07/schema#",
}
result = _build_vertex_schema_for_gemini_2(schema)
assert "additionalProperties" not in result
assert "$schema" not in result
def test_no_type_coercion(self):
"""Schemas without type should NOT have type: object added."""
schema = {
"type": "object",
"properties": {
"data": {"description": "Any data"},
},
}
result = _build_vertex_schema_for_gemini_2(schema)
assert "type" not in result["properties"]["data"]
def test_items_empty_preserved(self):
"""items: {} should NOT be coerced to items: {"type": "object"}."""
schema = {
"type": "object",
"properties": {
"values": {"type": "array", "items": {}},
},
}
result = _build_vertex_schema_for_gemini_2(schema)
assert result["properties"]["values"]["items"] == {}
@@ -2093,150 +2093,3 @@ async def test_get_tools_from_mcp_servers_logs_list_tools_to_spendlogs_when_enab
assert spend_meta["tool_count_total"] == 1
assert spend_meta["allowed_server_count"] == 1
assert spend_meta["per_server_tool_counts"]["server_a"] == 1
def test_tool_name_matches_case_insensitive():
"""Test that _tool_name_matches performs case-insensitive comparison.
This is critical for OpenAPI-based MCP servers where:
1. operationIds are often in camelCase (e.g., 'addPet', 'updatePet')
2. Tool names are lowercased during registration (e.g., 'addpet', 'updatepet')
3. allowed_tools configuration may use the original camelCase names
Without case-insensitive matching, all tools would be filtered out.
"""
try:
from litellm.proxy._experimental.mcp_server.server import _tool_name_matches
except ImportError:
pytest.skip("MCP server not available")
# Test case 1: Unprefixed tool name with camelCase in filter list
assert _tool_name_matches("addpet", ["addPet", "updatePet"]) is True
assert _tool_name_matches("updatepet", ["addPet", "updatePet"]) is True
assert _tool_name_matches("deletepet", ["addPet", "updatePet"]) is False
# Test case 2: Prefixed tool name with camelCase in filter list
assert _tool_name_matches("per_store-addpet", ["addPet", "updatePet"]) is True
assert _tool_name_matches("per_store-updatepet", ["addPet", "updatePet"]) is True
assert _tool_name_matches("per_store-deletepet", ["addPet", "updatePet"]) is False
# Test case 3: Mixed case variations
assert _tool_name_matches("findPetsByStatus", ["findpetsbystatus"]) is True
assert _tool_name_matches("findpetsbystatus", ["findPetsByStatus"]) is True
assert _tool_name_matches("FINDPETSBYSTATUS", ["findPetsByStatus"]) is True
# Test case 4: Full prefixed name in filter list (case-insensitive)
assert _tool_name_matches("server-addPet", ["server-addpet"]) is True
assert _tool_name_matches("server-addpet", ["server-addPet"]) is True
# Test case 5: Ensure non-matching names still don't match
assert _tool_name_matches("addpet", ["deletePet", "updatePet"]) is False
assert _tool_name_matches("server-addpet", ["deletePet", "updatePet"]) is False
def test_filter_tools_by_allowed_tools_case_insensitive():
"""Test that filter_tools_by_allowed_tools handles case-insensitive matching.
Ensures that OpenAPI tools with lowercase names can be filtered using
camelCase allowed_tools configuration from the OpenAPI spec.
"""
try:
from litellm.proxy._experimental.mcp_server.server import (
filter_tools_by_allowed_tools,
)
from litellm.types.mcp_server.tool_registry import MCPTool
except ImportError:
pytest.skip("MCP server not available")
# Mock handler function
def mock_handler(**kwargs):
return kwargs
# Create mock tools with lowercase names (as registered from OpenAPI)
tools = [
MCPTool(
name="per_store-addpet",
description="Add a pet",
input_schema={"type": "object"},
handler=mock_handler,
),
MCPTool(
name="per_store-updatepet",
description="Update a pet",
input_schema={"type": "object"},
handler=mock_handler,
),
MCPTool(
name="per_store-deletepet",
description="Delete a pet",
input_schema={"type": "object"},
handler=mock_handler,
),
MCPTool(
name="per_store-findpetsbystatus",
description="Find pets by status",
input_schema={"type": "object"},
handler=mock_handler,
),
]
# Create mock server with camelCase allowed_tools (as from OpenAPI spec)
server = MCPServer(
server_id="test-server",
name="per_store",
transport=MCPTransport.http,
allowed_tools=["addPet", "updatePet", "findPetsByStatus"],
)
# Filter tools
filtered_tools = filter_tools_by_allowed_tools(tools, server)
# Should return 3 tools (case-insensitive match)
assert len(filtered_tools) == 3
assert any(t.name == "per_store-addpet" for t in filtered_tools)
assert any(t.name == "per_store-updatepet" for t in filtered_tools)
assert any(t.name == "per_store-findpetsbystatus" for t in filtered_tools)
assert not any(t.name == "per_store-deletepet" for t in filtered_tools)
def test_filter_tools_by_allowed_tools_no_filter():
"""Test that filter_tools_by_allowed_tools returns all tools when no filter is set."""
try:
from litellm.proxy._experimental.mcp_server.server import (
filter_tools_by_allowed_tools,
)
from litellm.types.mcp_server.tool_registry import MCPTool
except ImportError:
pytest.skip("MCP server not available")
# Mock handler function
def mock_handler(**kwargs):
return kwargs
tools = [
MCPTool(
name="fusion_litellm_mcp-model_list",
description="List models",
input_schema={"type": "object"},
handler=mock_handler,
),
MCPTool(
name="fusion_litellm_mcp-chat_completion",
description="Chat completion",
input_schema={"type": "object"},
handler=mock_handler,
),
]
# Server with no allowed_tools filter
server = MCPServer(
server_id="test-server",
name="fusion_litellm_mcp",
transport=MCPTransport.http,
allowed_tools=None,
)
filtered_tools = filter_tools_by_allowed_tools(tools, server)
# Should return all tools when no filter is configured
assert len(filtered_tools) == 2
@@ -21,140 +21,6 @@ def test_get_team_models_for_all_models_and_team_only_models():
assert set(result) == set(combined_models)
def test_get_team_models_all_proxy_models_includes_access_groups():
"""
When a team has 'all-proxy-models' and include_model_access_groups=True,
the result should include model access group names (e.g. 'claude-model-group')
in addition to individual model names.
"""
from litellm.proxy.auth.model_checks import get_team_models
team_models = ["all-proxy-models"]
proxy_model_list = ["model1", "model2"]
model_access_groups = {
"group-a": ["model1"],
"group-b": ["model2"],
}
result = get_team_models(
team_models, proxy_model_list, model_access_groups, include_model_access_groups=True
)
assert "group-a" in result
assert "group-b" in result
assert "model1" in result
assert "model2" in result
assert len(result) == len(set(result)), "result should have no duplicates"
def test_get_team_models_all_proxy_models_without_include_flag():
"""
When include_model_access_groups=False, access group names should NOT
appear in the result even with 'all-proxy-models'.
"""
from litellm.proxy.auth.model_checks import get_team_models
team_models = ["all-proxy-models"]
proxy_model_list = ["model1", "model2"]
model_access_groups = {
"group-a": ["model1"],
"group-b": ["model2"],
}
result = get_team_models(
team_models, proxy_model_list, model_access_groups, include_model_access_groups=False
)
assert "group-a" not in result
assert "group-b" not in result
assert "model1" in result
assert "model2" in result
def test_get_key_models_all_proxy_models_includes_access_groups():
"""
When a key has 'all-proxy-models' and include_model_access_groups=True,
the result should include model access group names.
"""
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.model_checks import get_key_models
user_api_key_dict = UserAPIKeyAuth(
models=["all-proxy-models"],
api_key="test-key",
)
proxy_model_list = ["model1", "model2"]
model_access_groups = {
"group-a": ["model1"],
}
result = get_key_models(
user_api_key_dict=user_api_key_dict,
proxy_model_list=proxy_model_list,
model_access_groups=model_access_groups,
include_model_access_groups=True,
)
assert "group-a" in result
assert "model1" in result
assert "model2" in result
assert len(result) == len(set(result)), "result should have no duplicates"
def test_get_key_models_passes_include_model_access_groups():
"""
When a key explicitly has an access group name in its models list and
include_model_access_groups=True, the group name should be retained
(not stripped by _get_models_from_access_groups).
"""
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.model_checks import get_key_models
user_api_key_dict = UserAPIKeyAuth(
models=["group-a"],
api_key="test-key",
)
proxy_model_list = ["model1", "model2"]
model_access_groups = {
"group-a": ["model1", "model2"],
}
result = get_key_models(
user_api_key_dict=user_api_key_dict,
proxy_model_list=proxy_model_list,
model_access_groups=model_access_groups,
include_model_access_groups=True,
)
assert "group-a" in result
assert "model1" in result
assert "model2" in result
def test_get_key_models_does_not_mutate_input():
"""
get_key_models must not mutate user_api_key_dict.models in-place.
_get_models_from_access_groups uses .pop()/.extend() which would corrupt
cached UserAPIKeyAuth objects if all_models were an alias instead of a copy.
"""
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.model_checks import get_key_models
original_models = ["group-a", "extra-model"]
user_api_key_dict = UserAPIKeyAuth(
models=list(original_models), # give it a list
api_key="test-key",
)
model_access_groups = {
"group-a": ["model1", "model2"],
}
_ = get_key_models(
user_api_key_dict=user_api_key_dict,
proxy_model_list=["model1", "model2"],
model_access_groups=model_access_groups,
include_model_access_groups=False,
)
# The original models list on the auth object must be unchanged
assert user_api_key_dict.models == original_models
@pytest.mark.parametrize(
"key_models,team_models,proxy_model_list,model_list,expected",
[
File diff suppressed because it is too large Load Diff
@@ -2369,42 +2369,3 @@ def test_get_registered_pass_through_route_with_custom_root():
# Clean up
_registered_pass_through_routes.clear()
def test_mapped_pass_through_routes_with_server_root_path():
"""
Mapped passthrough routes (vertex_ai, bedrock, etc) should match
even when SERVER_ROOT_PATH is set and the incoming route is prefixed.
Regression test for https://github.com/BerriAI/litellm/issues/22272
"""
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
InitPassThroughEndpointHelpers,
)
with patch(
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path"
) as mock_get_root:
mock_get_root.return_value = "/litellm"
# prefixed route should match mapped routes like /vertex_ai
assert (
InitPassThroughEndpointHelpers.is_registered_pass_through_route(
"/litellm/vertex_ai/v1/projects/foo"
)
is True
)
assert (
InitPassThroughEndpointHelpers.is_registered_pass_through_route(
"/litellm/bedrock/model/invoke"
)
is True
)
# bare route without prefix should not match when root is set
assert (
InitPassThroughEndpointHelpers.is_registered_pass_through_route(
"/vertex_ai/v1/projects/foo"
)
is False
)
@@ -1071,10 +1071,9 @@ def test_spend_logs_redacts_request_and_response_when_turn_off_message_logging_e
response_result = _get_response_for_spend_logs_payload(payload=payload, kwargs=kwargs)
# When redaction is enabled and response is a dict (not ModelResponse),
# perform_redaction redacts content in-place within the choices structure
# perform_redaction returns {"text": "redacted-by-litellm"}
parsed_response = json.loads(response_result)
assert parsed_response["choices"][0]["message"]["content"] == "redacted-by-litellm"
assert parsed_response["choices"][0]["message"]["role"] == "assistant"
assert parsed_response == {"text": "redacted-by-litellm"}
@patch("litellm.secret_managers.main.get_secret_bool")
@@ -1,142 +0,0 @@
"""
Test that the OpenAPI schema generated by FastAPI is valid for specific endpoints.
Validates fixes for:
- /spend/calculate response schema (must use proper OpenAPI 3.x content wrapper)
- /credentials/by_model/{model_id} path parameter (must not leak credential_name)
Related issue: https://github.com/BerriAI/litellm/issues/21305
"""
import pytest
class TestSpendCalculateOpenAPISchema:
"""Test /spend/calculate response schema is valid OpenAPI 3.x."""
def test_response_schema_has_description(self):
"""The 200 response must have a 'description' field per OpenAPI 3.x spec."""
from litellm.proxy.spend_tracking.spend_management_endpoints import router
for route in router.routes:
if hasattr(route, "path") and route.path == "/spend/calculate":
responses = route.responses or {}
response_200 = responses.get(200, {})
assert "description" in response_200, (
"/spend/calculate 200 response must have a 'description' field"
)
break
else:
pytest.fail("/spend/calculate route not found in router")
def test_response_schema_has_content_wrapper(self):
"""The 200 response must use 'content' wrapper, not bare properties."""
from litellm.proxy.spend_tracking.spend_management_endpoints import router
for route in router.routes:
if hasattr(route, "path") and route.path == "/spend/calculate":
responses = route.responses or {}
response_200 = responses.get(200, {})
# Must NOT have 'cost' as a top-level key (invalid OpenAPI)
assert "cost" not in response_200, (
"/spend/calculate 200 response must not have 'cost' as a "
"top-level property - use 'content' wrapper instead"
)
# Must have 'content' wrapper
assert "content" in response_200, (
"/spend/calculate 200 response must have a 'content' field"
)
content = response_200["content"]
assert "application/json" in content
assert "schema" in content["application/json"]
break
else:
pytest.fail("/spend/calculate route not found in router")
class TestCredentialEndpointsOpenAPISchema:
"""Test /credentials endpoints have correct path parameters."""
def test_by_name_and_by_model_are_separate_handlers(self):
"""
/credentials/by_name/{credential_name} and /credentials/by_model/{model_id}
must be separate handler functions so each only declares its own path params.
"""
from litellm.proxy.credential_endpoints.endpoints import router
by_name_routes = []
by_model_routes = []
for route in router.routes:
if not hasattr(route, "path"):
continue
if "by_name" in route.path:
by_name_routes.append(route)
elif "by_model" in route.path:
by_model_routes.append(route)
assert len(by_name_routes) == 1, "Expected exactly one by_name route"
assert len(by_model_routes) == 1, "Expected exactly one by_model route"
# They must be different endpoint functions
by_name_endpoint = by_name_routes[0].endpoint
by_model_endpoint = by_model_routes[0].endpoint
assert by_name_endpoint is not by_model_endpoint, (
"by_name and by_model must be separate handler functions "
"to avoid path parameter conflicts in OpenAPI spec"
)
def test_by_model_route_does_not_require_credential_name(self):
"""
The /credentials/by_model/{model_id} route must NOT have
credential_name as a parameter.
"""
import inspect
from litellm.proxy.credential_endpoints.endpoints import (
get_credential_by_model,
)
sig = inspect.signature(get_credential_by_model)
param_names = list(sig.parameters.keys())
assert "credential_name" not in param_names, (
"get_credential_by_model must not have a credential_name parameter"
)
def test_by_name_route_does_not_require_model_id(self):
"""
The /credentials/by_name/{credential_name} route must NOT have
model_id as a parameter.
"""
import inspect
from litellm.proxy.credential_endpoints.endpoints import (
get_credential_by_name,
)
sig = inspect.signature(get_credential_by_name)
param_names = list(sig.parameters.keys())
assert "model_id" not in param_names, (
"get_credential_by_name must not have a model_id parameter"
)
def test_by_model_has_model_id_path_param(self):
"""The by_model handler must accept model_id as a path parameter."""
import inspect
from litellm.proxy.credential_endpoints.endpoints import (
get_credential_by_model,
)
sig = inspect.signature(get_credential_by_model)
assert "model_id" in sig.parameters, (
"get_credential_by_model must have a model_id parameter"
)
def test_by_name_has_credential_name_path_param(self):
"""The by_name handler must accept credential_name as a path parameter."""
import inspect
from litellm.proxy.credential_endpoints.endpoints import (
get_credential_by_name,
)
sig = inspect.signature(get_credential_by_name)
assert "credential_name" in sig.parameters, (
"get_credential_by_name must have a credential_name parameter"
)
@@ -1774,128 +1774,3 @@ class TestStreamingIDConsistency:
# Verify it matches the cached ID
assert iterator._cached_item_id is not None
assert iterator._cached_item_id == text_done_id
def test_parallel_tool_calls_merged_into_single_assistant_message(self):
"""
Regression test: multi-turn parallel tool calls via the Responses API must
produce a single assistant message with all tool_calls, not one assistant
message per function_call item.
When the model responds with two parallel tool calls (e.g. get_weather for
SF and NYC), the next Responses API request includes two consecutive
function_call items followed by two function_call_output items.
Without the fix each function_call becomes its own assistant message,
producing back-to-back assistant messages that Anthropic/Vertex AI rejects:
"tool_use ids were found without tool_result blocks immediately after".
"""
input_items = [
{"type": "message", "role": "user", "content": "Weather in SF and NYC?"},
# Two parallel tool calls from the previous assistant response
{
"type": "function_call",
"call_id": "toolu_01",
"name": "get_weather",
"arguments": '{"city": "SF"}',
},
{
"type": "function_call",
"call_id": "toolu_02",
"name": "get_weather",
"arguments": '{"city": "NYC"}',
},
# Tool results
{"type": "function_call_output", "call_id": "toolu_01", "output": "72°F"},
{"type": "function_call_output", "call_id": "toolu_02", "output": "55°F"},
]
messages = LiteLLMCompletionResponsesConfig._transform_response_input_param_to_chat_completion_message(
input=input_items
)
roles = [
m.get("role") if isinstance(m, dict) else getattr(m, "role", None)
for m in messages
]
# Must not have two consecutive assistant messages
for i in range(len(roles) - 1):
assert not (
roles[i] == "assistant" and roles[i + 1] == "assistant"
), f"Consecutive assistant messages at indices {i} and {i+1}: {roles}"
# The single assistant message must contain BOTH tool_calls
assistant_messages = [
m for m in messages
if (m.get("role") if isinstance(m, dict) else getattr(m, "role", None))
== "assistant"
]
assert len(assistant_messages) == 1, (
f"Expected 1 assistant message, got {len(assistant_messages)}"
)
assistant_msg = assistant_messages[0]
tool_calls = (
assistant_msg.get("tool_calls")
if isinstance(assistant_msg, dict)
else getattr(assistant_msg, "tool_calls", None)
)
assert tool_calls is not None and len(tool_calls) == 2, (
f"Expected 2 tool_calls in the merged assistant message, got: {tool_calls}"
)
call_ids = [
(tc.get("id") if isinstance(tc, dict) else getattr(tc, "id", None))
for tc in tool_calls
]
assert "toolu_01" in call_ids, f"toolu_01 missing from tool_calls: {call_ids}"
assert "toolu_02" in call_ids, f"toolu_02 missing from tool_calls: {call_ids}"
# Both tool messages must be present
tool_messages = [
m for m in messages
if (m.get("role") if isinstance(m, dict) else getattr(m, "role", None))
== "tool"
]
assert len(tool_messages) == 2, (
f"Expected 2 tool messages, got {len(tool_messages)}"
)
def test_single_tool_call_still_works_after_merge_fix(self):
"""
Ensure the parallel-tool-call merging fix does not break the existing
single-tool-call path.
"""
input_items = [
{"type": "message", "role": "user", "content": "Weather in SF?"},
{
"type": "function_call",
"call_id": "toolu_01",
"name": "get_weather",
"arguments": '{"city": "SF"}',
},
{"type": "function_call_output", "call_id": "toolu_01", "output": "72°F"},
]
messages = LiteLLMCompletionResponsesConfig._transform_response_input_param_to_chat_completion_message(
input=input_items
)
roles = [
m.get("role") if isinstance(m, dict) else getattr(m, "role", None)
for m in messages
]
assert "user" in roles
assert "assistant" in roles
assert "tool" in roles
assistant_messages = [m for m in messages if (m.get("role") if isinstance(m, dict) else getattr(m, "role", None)) == "assistant"]
assert len(assistant_messages) == 1
tool_calls = (
assistant_messages[0].get("tool_calls")
if isinstance(assistant_messages[0], dict)
else getattr(assistant_messages[0], "tool_calls", None)
)
assert tool_calls is not None and len(tool_calls) == 1
@@ -0,0 +1,238 @@
"""
Tests for the ``aliases`` feature in the model cost map.
The ``_expand_model_aliases`` function processes ``aliases`` lists from model
entries, creating shared dict references for alias entries at load time.
"""
import logging
from litellm.litellm_core_utils.get_model_cost_map import _expand_model_aliases
# ---------------------------------------------------------------------------
# Core expansion behaviour
# ---------------------------------------------------------------------------
class TestExpandModelAliases:
"""Unit tests for _expand_model_aliases."""
def test_basic_expansion(self):
"""Aliases are added as top-level entries in model_cost."""
model_cost = {
"my-model-latest": {
"aliases": ["my-model-20250101"],
"input_cost_per_token": 1e-06,
"litellm_provider": "test",
"mode": "chat",
},
}
result = _expand_model_aliases(model_cost)
assert "my-model-20250101" in result
assert result["my-model-20250101"]["input_cost_per_token"] == 1e-06
assert result["my-model-20250101"]["litellm_provider"] == "test"
def test_multiple_aliases(self):
"""A single entry can declare multiple aliases."""
model_cost = {
"provider/model-latest": {
"aliases": ["provider/model-v1", "provider/model-v2"],
"input_cost_per_token": 5e-06,
"litellm_provider": "provider",
"mode": "chat",
},
}
result = _expand_model_aliases(model_cost)
assert "provider/model-v1" in result
assert "provider/model-v2" in result
def test_shared_dict_reference(self):
"""Alias entries share the same dict object as the canonical entry (no copy)."""
model_cost = {
"canonical-model": {
"aliases": ["alias-model"],
"input_cost_per_token": 2e-06,
"litellm_provider": "test",
"mode": "chat",
},
}
result = _expand_model_aliases(model_cost)
assert result["alias-model"] is result["canonical-model"]
def test_aliases_key_removed(self):
"""The ``aliases`` key is removed from the entry after expansion."""
model_cost = {
"my-model": {
"aliases": ["my-model-alias"],
"input_cost_per_token": 1e-06,
"litellm_provider": "test",
"mode": "chat",
},
}
result = _expand_model_aliases(model_cost)
assert "aliases" not in result["my-model"]
assert "aliases" not in result["my-model-alias"]
def test_entries_without_aliases_unchanged(self):
"""Entries with no ``aliases`` key are left untouched."""
model_cost = {
"plain-model": {
"input_cost_per_token": 3e-06,
"litellm_provider": "test",
"mode": "chat",
},
}
result = _expand_model_aliases(model_cost)
assert "plain-model" in result
assert result["plain-model"]["input_cost_per_token"] == 3e-06
assert len(result) == 1
def test_empty_aliases_list(self):
"""An empty ``aliases`` list is treated the same as no aliases."""
model_cost = {
"model-a": {
"aliases": [],
"input_cost_per_token": 1e-06,
"litellm_provider": "test",
"mode": "chat",
},
}
result = _expand_model_aliases(model_cost)
assert len(result) == 1
assert "model-a" in result
assert "aliases" not in result["model-a"]
# ---------------------------------------------------------------------------
# Conflict handling
# ---------------------------------------------------------------------------
class TestAliasConflicts:
"""Tests for alias conflict detection and handling."""
def test_alias_conflicts_with_canonical_entry(self, caplog):
"""Alias that matches an existing canonical entry is skipped with a warning."""
model_cost = {
"model-latest": {
"aliases": ["model-dated"],
"input_cost_per_token": 1e-06,
"litellm_provider": "test",
"mode": "chat",
},
"model-dated": {
"input_cost_per_token": 1e-06,
"litellm_provider": "test",
"mode": "chat",
},
}
with caplog.at_level(logging.WARNING, logger="LiteLLM"):
result = _expand_model_aliases(model_cost)
# The canonical "model-dated" entry is preserved, not overwritten
assert "model-dated" in result
assert "alias conflict" in caplog.text.lower()
def test_duplicate_alias_across_entries(self, caplog):
"""Same alias claimed by two different entries: second one is skipped."""
model_cost = {
"model-a": {
"aliases": ["shared-alias"],
"input_cost_per_token": 1e-06,
"litellm_provider": "test",
"mode": "chat",
},
"model-b": {
"aliases": ["shared-alias"],
"input_cost_per_token": 2e-06,
"litellm_provider": "test",
"mode": "chat",
},
}
with caplog.at_level(logging.WARNING, logger="LiteLLM"):
result = _expand_model_aliases(model_cost)
# "shared-alias" should point to model-a (first one wins)
assert "shared-alias" in result
assert result["shared-alias"]["input_cost_per_token"] == 1e-06
assert "alias conflict" in caplog.text.lower()
def test_canonical_entry_not_overwritten_by_alias(self):
"""An alias must never overwrite an existing canonical entry's data."""
original_cost = 9.99e-06
model_cost = {
"existing-model": {
"input_cost_per_token": original_cost,
"litellm_provider": "test",
"mode": "chat",
},
"other-model": {
"aliases": ["existing-model"],
"input_cost_per_token": 1e-06,
"litellm_provider": "test",
"mode": "chat",
},
}
result = _expand_model_aliases(model_cost)
# Original entry must be preserved
assert result["existing-model"]["input_cost_per_token"] == original_cost
# ---------------------------------------------------------------------------
# Integration with model_cost dict mutation
# ---------------------------------------------------------------------------
class TestAliasIntegration:
"""Higher-level tests verifying aliases work with the model_cost dict."""
def test_mutation_through_alias_visible_on_canonical(self):
"""Since alias is a shared reference, mutations are visible on both."""
model_cost = {
"canonical": {
"aliases": ["alias"],
"input_cost_per_token": 1e-06,
"litellm_provider": "test",
"mode": "chat",
},
}
result = _expand_model_aliases(model_cost)
# Mutate via alias
result["alias"]["input_cost_per_token"] = 999
assert result["canonical"]["input_cost_per_token"] == 999
def test_mixed_entries_with_and_without_aliases(self):
"""A model_cost dict with a mix of aliased and plain entries."""
model_cost = {
"model-with-alias": {
"aliases": ["alias-1", "alias-2"],
"input_cost_per_token": 1e-06,
"litellm_provider": "test",
"mode": "chat",
},
"plain-model": {
"input_cost_per_token": 2e-06,
"litellm_provider": "test",
"mode": "chat",
},
}
result = _expand_model_aliases(model_cost)
assert len(result) == 4 # 2 canonical + 2 aliases
assert "alias-1" in result
assert "alias-2" in result
assert "plain-model" in result
assert "model-with-alias" in result
def test_expand_on_empty_dict(self):
"""Expanding an empty dict returns an empty dict."""
assert _expand_model_aliases({}) == {}
@@ -1,251 +0,0 @@
"""
Test that the Router retry loop correctly handles non-retryable errors.
Verifies that:
1. Non-retryable errors (e.g., 400 ContextWindowExceeded) inside the retry loop
break out immediately instead of being swallowed.
2. original_exception is updated to the latest error, not stuck on the first.
3. Retryable errors (e.g., 429 RateLimitError) still retry normally.
Regression tests for https://github.com/BerriAI/litellm/issues/21343
"""
from unittest.mock import AsyncMock, patch
import pytest
import litellm
from litellm import Router
def _make_rate_limit_error(message="Rate limited"):
"""Create a RateLimitError for testing."""
return litellm.RateLimitError(
message=message,
llm_provider="bedrock",
model="anthropic.claude-v2",
)
def _make_context_window_error(message="prompt is too long: 1205821 tokens > 200000"):
"""Create a ContextWindowExceededError for testing."""
return litellm.ContextWindowExceededError(
message=message,
llm_provider="vertex_ai",
model="claude-3-opus",
)
def _make_bad_request_error(message="Invalid request"):
"""Create a BadRequestError for testing."""
return litellm.BadRequestError(
message=message,
llm_provider="openai",
model="gpt-4",
)
def _make_not_found_error(message="Model not found"):
"""Create a NotFoundError for testing."""
return litellm.NotFoundError(
message=message,
llm_provider="openai",
model="gpt-99",
)
def _create_router(num_retries=2):
"""Create a Router with two deployments for testing."""
return Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {
"model": "openai/gpt-4",
"api_key": "fake-key-1",
},
},
{
"model_name": "test-model",
"litellm_params": {
"model": "openai/gpt-4",
"api_key": "fake-key-2",
},
},
],
num_retries=num_retries,
)
def _base_kwargs():
"""Return kwargs required by async_function_with_retries."""
return {
"model": "test-model",
"messages": [{"role": "user", "content": "test"}],
"original_function": AsyncMock(),
"metadata": {},
}
@pytest.mark.asyncio
async def test_non_retryable_error_in_retry_loop_raises_immediately():
"""
When a non-retryable error (400 ContextWindowExceeded) occurs inside the
retry loop, the router should raise it immediately instead of swallowing it
and raising the original error.
Scenario: First call -> 429, Retry -> 400 (non-retryable)
Expected: ContextWindowExceededError is raised, NOT RateLimitError
"""
router = _create_router(num_retries=2)
rate_limit_error = _make_rate_limit_error()
context_window_error = _make_context_window_error()
call_count = 0
async def mock_make_call(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
raise rate_limit_error
else:
raise context_window_error
with patch.object(router, "make_call", side_effect=mock_make_call), \
patch.object(router, "_async_get_healthy_deployments",
return_value=(["d1", "d2"], ["d1", "d2"])), \
patch.object(router, "_time_to_sleep_before_retry", return_value=0), \
patch.object(router, "log_retry", side_effect=lambda kwargs, e: kwargs):
with pytest.raises(litellm.ContextWindowExceededError):
await router.async_function_with_retries(
num_retries=2,
**_base_kwargs(),
)
@pytest.mark.asyncio
async def test_bad_request_error_in_retry_loop_raises_immediately():
"""
A generic 400 BadRequestError inside the retry loop should also break out
immediately since 400 is not retryable.
"""
router = _create_router(num_retries=2)
rate_limit_error = _make_rate_limit_error()
bad_request_error = _make_bad_request_error()
call_count = 0
async def mock_make_call(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
raise rate_limit_error
else:
raise bad_request_error
with patch.object(router, "make_call", side_effect=mock_make_call), \
patch.object(router, "_async_get_healthy_deployments",
return_value=(["d1", "d2"], ["d1", "d2"])), \
patch.object(router, "_time_to_sleep_before_retry", return_value=0), \
patch.object(router, "log_retry", side_effect=lambda kwargs, e: kwargs):
with pytest.raises(litellm.BadRequestError):
await router.async_function_with_retries(
num_retries=2,
**_base_kwargs(),
)
@pytest.mark.asyncio
async def test_original_exception_updated_to_latest_error():
"""
When all retries are exhausted with retryable errors, the LAST error
should be raised, not the first one.
"""
router = _create_router(num_retries=2)
call_count = 0
async def mock_make_call(*args, **kwargs):
nonlocal call_count
call_count += 1
raise _make_rate_limit_error(f"Rate limit attempt {call_count}")
with patch.object(router, "make_call", side_effect=mock_make_call), \
patch.object(router, "_async_get_healthy_deployments",
return_value=(["d1", "d2"], ["d1", "d2"])), \
patch.object(router, "_time_to_sleep_before_retry", return_value=0), \
patch.object(router, "log_retry", side_effect=lambda kwargs, e: kwargs):
with pytest.raises(litellm.RateLimitError) as exc_info:
await router.async_function_with_retries(
num_retries=2,
**_base_kwargs(),
)
# Should be the LAST error, not the first
assert "Rate limit attempt 3" in str(exc_info.value)
@pytest.mark.asyncio
async def test_retryable_errors_still_retry_normally():
"""
Retryable errors (429 RateLimitError) should still be retried the
configured number of times before raising.
"""
router = _create_router(num_retries=3)
call_count = 0
async def mock_make_call(*args, **kwargs):
nonlocal call_count
call_count += 1
raise _make_rate_limit_error(f"Rate limit attempt {call_count}")
with patch.object(router, "make_call", side_effect=mock_make_call), \
patch.object(router, "_async_get_healthy_deployments",
return_value=(["d1", "d2"], ["d1", "d2"])), \
patch.object(router, "_time_to_sleep_before_retry", return_value=0), \
patch.object(router, "log_retry", side_effect=lambda kwargs, e: kwargs):
with pytest.raises(litellm.RateLimitError):
await router.async_function_with_retries(
num_retries=3,
**_base_kwargs(),
)
# Initial call + 3 retries = 4 total calls
assert call_count == 4
@pytest.mark.asyncio
async def test_not_found_error_in_retry_loop_raises_immediately():
"""
A 404 NotFoundError inside the retry loop should break out immediately.
"""
router = _create_router(num_retries=2)
rate_limit_error = _make_rate_limit_error()
not_found_error = _make_not_found_error()
call_count = 0
async def mock_make_call(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
raise rate_limit_error
else:
raise not_found_error
with patch.object(router, "make_call", side_effect=mock_make_call), \
patch.object(router, "_async_get_healthy_deployments",
return_value=(["d1", "d2"], ["d1", "d2"])), \
patch.object(router, "_time_to_sleep_before_retry", return_value=0), \
patch.object(router, "log_retry", side_effect=lambda kwargs, e: kwargs):
with pytest.raises(litellm.NotFoundError):
await router.async_function_with_retries(
num_retries=2,
**_base_kwargs(),
)
# Only 2 calls: initial + first retry that hits non-retryable
assert call_count == 2
@@ -225,6 +225,60 @@ def test_chat_completion_token_logprob_invalid_top_logprobs_rejected():
)
# ---------------------------------------------------------------------------
# native_finish_reason in provider_specific_fields
# ---------------------------------------------------------------------------
class TestNativeFinishReason:
"""Choices exposes the raw provider finish_reason in provider_specific_fields
when it differs from the mapped OpenAI-compatible value."""
def test_provider_reason_exposed_when_mapped(self):
from litellm.types.utils import Choices
choice = Choices(finish_reason="end_turn")
assert choice.finish_reason == "stop"
assert choice.provider_specific_fields["native_finish_reason"] == "end_turn"
def test_provider_reason_not_set_when_already_openai(self):
from litellm.types.utils import Choices
choice = Choices(finish_reason="stop")
assert choice.finish_reason == "stop"
assert not hasattr(choice, "provider_specific_fields")
def test_provider_reason_merged_with_existing_fields(self):
from litellm.types.utils import Choices
choice = Choices(
finish_reason="max_tokens",
provider_specific_fields={"citations": [{"url": "http://example.com"}]},
)
assert choice.finish_reason == "length"
assert choice.provider_specific_fields["native_finish_reason"] == "max_tokens"
assert choice.provider_specific_fields["citations"] == [{"url": "http://example.com"}]
def test_gemini_safety_reason_exposed(self):
from litellm.types.utils import Choices
choice = Choices(finish_reason="SAFETY")
assert choice.finish_reason == "content_filter"
assert choice.provider_specific_fields["native_finish_reason"] == "SAFETY"
def test_anthropic_tool_use_reason_exposed(self):
from litellm.types.utils import Choices
choice = Choices(finish_reason="tool_use")
assert choice.finish_reason == "tool_calls"
assert choice.provider_specific_fields["native_finish_reason"] == "tool_use"
def test_max_tokens_reason_exposed(self):
from litellm.types.utils import Choices
choice = Choices(finish_reason="MAX_TOKENS")
assert choice.finish_reason == "length"
assert choice.provider_specific_fields["native_finish_reason"] == "MAX_TOKENS"
def test_delta_maps_reasoning_to_reasoning_content():
"""
Test that Delta maps 'reasoning' field to 'reasoning_content'.
@@ -275,8 +275,8 @@ it("should display user email correctly", async () => {
});
});
it("should show loading message only on initial load (isPending)", () => {
// Mock initial loading state
it("should show skeleton loaders when isLoading is true", () => {
// Mock loading state
mockUseKeys.mockReturnValue({
data: null,
isPending: true,
@@ -296,7 +296,7 @@ it("should show loading message only on initial load (isPending)", () => {
renderWithProviders(<VirtualKeysTable {...mockProps} />);
// Check that loading message is shown on initial load
// Check that loading message is shown
expect(screen.getByText("🚅 Loading keys...")).toBeInTheDocument();
// Check that actual key data is not shown
@@ -810,79 +810,3 @@ describe("pagination display total count and page count", () => {
});
});
});
describe("refetch button", () => {
it("should show Fetch button in normal state", () => {
renderWithProviders(<VirtualKeysTable {...defaultMockProps} />);
const fetchButton = screen.getByTitle("Fetch data");
expect(fetchButton).toBeInTheDocument();
expect(fetchButton).not.toBeDisabled();
expect(screen.getByText("Fetch")).toBeInTheDocument();
});
it("should show Fetching state and keep table data visible during refetch", () => {
mockUseKeys.mockReturnValue({
data: {
keys: [mockKey],
total_count: 1,
current_page: 1,
total_pages: 1,
} as KeysResponse,
isPending: false,
isFetching: true,
refetch: vi.fn(),
} as any);
renderWithProviders(<VirtualKeysTable {...defaultMockProps} />);
// Button should show "Fetching" and be disabled
expect(screen.getByText("Fetching")).toBeInTheDocument();
const fetchButton = screen.getByTitle("Fetch data");
expect(fetchButton).toBeDisabled();
// Table data should still be visible (stale data)
expect(screen.getByText("Test Key Alias")).toBeInTheDocument();
// "Loading keys..." should NOT appear during refetch
expect(screen.queryByText("🚅 Loading keys...")).not.toBeInTheDocument();
});
it("should call refetch when Fetch button is clicked", () => {
const mockRefetch = vi.fn();
mockUseKeys.mockReturnValue({
data: {
keys: [mockKey],
total_count: 1,
current_page: 1,
total_pages: 1,
} as KeysResponse,
isPending: false,
isFetching: false,
refetch: mockRefetch,
} as any);
renderWithProviders(<VirtualKeysTable {...defaultMockProps} />);
const fetchButton = screen.getByTitle("Fetch data");
fireEvent.click(fetchButton);
expect(mockRefetch).toHaveBeenCalledTimes(1);
});
it("should show Fetch button enabled on error so user can retry", () => {
mockUseKeys.mockReturnValue({
data: null,
isPending: false,
isFetching: false,
isError: true,
refetch: vi.fn(),
} as any);
renderWithProviders(<VirtualKeysTable {...defaultMockProps} />);
const fetchButton = screen.getByTitle("Fetch data");
expect(fetchButton).not.toBeDisabled();
expect(screen.getByText("Fetch")).toBeInTheDocument();
});
});
@@ -85,7 +85,6 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
data: keys,
isPending: isLoading,
isFetching,
isError,
refetch,
} = useKeys(tablePagination.pageIndex + 1, tablePagination.pageSize, {
sortBy: sortBy || undefined,
@@ -103,15 +102,6 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
organizations,
});
// Defer the transition so the button stays in loading state until the table
// has rendered with the new data (mirrors the spend-logs pattern)
const isFetchingDeferred = useDeferredValue(isFetching);
const isButtonLoading = (isFetching || isFetchingDeferred) && !isError;
const handleRefresh = () => {
refetch();
};
const totalCount = filteredTotalCount ?? keys?.total_count ?? 0;
// Add a useEffect to call refresh when a key is created
@@ -679,28 +669,16 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
</div>
<div className="flex items-center justify-between w-full mb-4">
<div className="inline-flex items-center gap-2">
{isLoading ? (
<Skeleton.Node active style={{ width: 200, height: 20 }} />
) : (
<span className="inline-flex text-sm text-gray-700">
Showing {rangeLabel} of {totalCount} results
</span>
)}
<AntButton
type="default"
icon={<SyncOutlined spin={isButtonLoading} />}
onClick={handleRefresh}
disabled={isButtonLoading}
title="Fetch data"
>
{isButtonLoading ? "Fetching" : "Fetch"}
</AntButton>
</div>
{isLoading || isFetching ? (
<Skeleton.Node active style={{ width: 200, height: 20 }} />
) : (
<span className="inline-flex text-sm text-gray-700">
Showing {rangeLabel} of {totalCount} results
</span>
)}
<div className="inline-flex items-center gap-2">
{isLoading ? (
{isLoading || isFetching ? (
<Skeleton.Node active style={{ width: 74, height: 20 }} />
) : (
<span className="text-sm text-gray-700">
@@ -708,24 +686,24 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
</span>
)}
{isLoading ? (
{isLoading || isFetching ? (
<Skeleton.Button active size="small" style={{ width: 84, height: 30 }} />
) : (
<button
onClick={() => table.previousPage()}
disabled={isLoading || !table.getCanPreviousPage()}
disabled={isLoading || isFetching || !table.getCanPreviousPage()}
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
)}
{isLoading ? (
{isLoading || isFetching ? (
<Skeleton.Button active size="small" style={{ width: 58, height: 30 }} />
) : (
<button
onClick={() => table.nextPage()}
disabled={isLoading || !table.getCanNextPage()}
disabled={isLoading || isFetching || !table.getCanNextPage()}
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
@@ -810,7 +788,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
))}
</TableHead>
<TableBody>
{isLoading ? (
{isLoading || isFetching ? (
<TableRow>
<TableCell colSpan={columns.length} className="h-8 text-center">
<div className="text-center text-gray-500">