Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_team_member_total_spend_frontend

This commit is contained in:
Ryan Crabbe
2026-04-23 16:47:41 -07:00
85 changed files with 8104 additions and 839 deletions
+184 -540
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -39,7 +39,7 @@ jobs:
if: github.event.action == 'opened'
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.11"
python-version: "3.12"
- name: Auto-close if high-confidence duplicate
if: github.event.action == 'opened'
@@ -0,0 +1,65 @@
name: Create Release Branch
on:
workflow_dispatch:
inputs:
tag:
description: "Release tag (e.g. v1.83.0-stable) — branch will be named release/<tag>"
required: true
type: string
commit_hash:
description: "Full 40-char commit SHA the branch should point to"
required: true
type: string
workflow_call:
inputs:
tag:
description: "Release tag"
required: true
type: string
commit_hash:
description: "Full 40-char commit SHA the branch should point to"
required: true
type: string
permissions: {}
jobs:
create-branch:
name: Create Release Branch
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Validate inputs
env:
TAG: ${{ inputs.tag }}
COMMIT_HASH: ${{ inputs.commit_hash }}
run: |
if ! echo "${COMMIT_HASH}" | grep -qE '^[0-9a-f]{40}$'; then
echo "::error::commit_hash must be a full 40-character commit SHA"
exit 1
fi
if ! echo "${TAG}" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+'; then
echo "::error::tag must start with vX.Y.Z"
exit 1
fi
- name: Create release branch
env:
TAG: ${{ inputs.tag }}
COMMIT_HASH: ${{ inputs.commit_hash }}
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const tag = process.env.TAG;
const commitHash = process.env.COMMIT_HASH;
const branchName = `release/${tag}`;
await github.rest.git.createRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: `refs/heads/${branchName}`,
sha: commitHash,
});
core.info(`Created branch ${branchName} at ${commitHash}`);
+11
View File
@@ -102,6 +102,17 @@ jobs:
body: updatedBody,
draft: false,
});
} catch (error) {
core.setFailed(error.message);
}
create-branch:
name: Create Release Branch
needs: release
permissions:
contents: write
uses: ./.github/workflows/create-release-branch.yml
with:
tag: ${{ inputs.tag }}
commit_hash: ${{ inputs.commit_hash }}
@@ -29,7 +29,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.11"
python-version: "3.12"
- name: Set up uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.13"
python-version: "3.12"
- name: Scan for duplicate issues
env:
+7 -4
View File
@@ -2061,7 +2061,7 @@ assert isinstance(
## Media Resolution Control (Images & Videos)
For Gemini 3+ models, LiteLLM supports per-part media resolution control using OpenAI's `detail` parameter. This allows you to specify different resolution levels for individual images and videos in your request, whether using `image_url` or `file` content types.
LiteLLM supports per-part media resolution control using OpenAI's `detail` parameter for all Gemini models. This allows you to specify different resolution levels for individual images and videos in your request, whether using `image_url` or `file` content types.
**Supported `detail` values:**
- `"low"` - Maps to `media_resolution: "low"` (280 tokens for images, 70 tokens per frame for videos)
@@ -2146,12 +2146,12 @@ response = completion(
</Tabs>
:::info
**Per-Part Resolution:** Each image or video in your request can have its own `detail` setting, allowing mixed-resolution requests (e.g., a high-res chart alongside a low-res icon). This feature works with both `image_url` and `file` content types, and is only available for Gemini 3+ models.
**Per-Part Resolution:** Each image or video in your request can have its own `detail` setting, allowing mixed-resolution requests (e.g., a high-res chart alongside a low-res icon). This feature works with both `image_url` and `file` content types across all Gemini models.
:::
## Video Metadata Control
For Gemini 3+ models, LiteLLM supports fine-grained video processing control through the `video_metadata` field. This allows you to specify frame extraction rates and time ranges for video analysis.
LiteLLM supports fine-grained video processing control through the `video_metadata` field for all Gemini models (1.x, 2.x, 3+). This allows you to specify frame extraction rates and time ranges for video analysis.
**Supported `video_metadata` parameters:**
@@ -2168,8 +2168,11 @@ For Gemini 3+ models, LiteLLM supports fine-grained video processing control thr
- `fps` remains unchanged
:::
:::tip
Video clipping (`start_offset`/`end_offset`) and frame rate control (`fps`) are supported by all Gemini models, but analysis quality is significantly higher with the **Gemini 2.5 series** (e.g., `gemini-2.5-flash`, `gemini-2.5-pro`).
:::
:::warning
- **Gemini 3+ Only:** This feature is only available for Gemini 3.0 and newer models
- **Video Files Recommended:** While `video_metadata` is designed for video files, error handling for other media types is delegated to the Vertex AI API
- **File Formats Supported:** Works with `gs://`, `https://`, and base64-encoded video files
:::
+2 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "litellm-proxy-extras"
version = "0.4.67"
version = "0.4.68"
description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package."
readme = "README.md"
requires-python = ">=3.9"
@@ -25,7 +25,7 @@ required-version = "==0.10.9"
module-root = ""
[tool.commitizen]
version = "0.4.67"
version = "0.4.68"
version_files = [
"pyproject.toml:^version",
"../pyproject.toml:litellm-proxy-extras==",
+1
View File
@@ -410,6 +410,7 @@ def image_generation( # noqa: PLR0915
litellm.LlmProviders.RUNWAYML,
litellm.LlmProviders.VERTEX_AI,
litellm.LlmProviders.OPENROUTER,
litellm.LlmProviders.DASHSCOPE,
):
if image_generation_config is None:
raise ValueError(
+9 -3
View File
@@ -370,11 +370,17 @@ class LoggingWorker:
self._running_tasks.clear()
async def flush(self) -> None:
"""Flush the logging queue."""
"""Flush the logging queue.
Waits until every enqueued task has completed. ``queue.join()`` blocks
on the queue's unfinished-task counter (decremented by ``task_done()``),
so it correctly handles items that have been dequeued but whose
callback hasn't finished yet — ``queue.empty()`` would return True in
that window and cause us to skip the wait.
"""
if self._queue is None:
return
while not self._queue.empty():
await self._queue.join()
await self._queue.join()
async def clear_queue(self):
"""
@@ -452,7 +452,14 @@ def update_messages_with_model_file_ids(
for c in content:
if c["type"] == "file":
file_object = cast(ChatCompletionFileObject, c)
file_object_file_field = file_object["file"]
file_object_file_field = file_object.get("file")
if not isinstance(file_object_file_field, dict):
# Content block has `type: "file"` but not the
# OpenAI Chat Completions shape (e.g. a LangChain
# v1 standardized file block, or a provider-native
# shape that also uses `type: "file"`). Nothing to
# remap here, so skip instead of crashing.
continue
file_id = file_object_file_field.get("file_id")
format = file_object_file_field.get(
"format", get_format_from_file_id(file_id)
@@ -1060,7 +1067,12 @@ def get_file_ids_from_messages(messages: List[AllMessageValues]) -> List[str]:
for c in content:
if c["type"] == "file":
file_object = cast(ChatCompletionFileObject, c)
file_object_file_field = file_object["file"]
file_object_file_field = file_object.get("file")
if not isinstance(file_object_file_field, dict):
# Content block has `type: "file"` but not the
# OpenAI Chat Completions shape. No file_id to
# extract, so skip instead of raising KeyError.
continue
file_id = file_object_file_field.get("file_id")
if file_id:
file_ids.append(file_id)
@@ -15,6 +15,7 @@ import litellm.types
import litellm.types.llms
from litellm import verbose_logger
from litellm._uuid import uuid
from litellm.litellm_core_utils.url_utils import async_safe_get, safe_get
from litellm.llms.custom_httpx.http_handler import HTTPHandler, get_async_httpx_client
from litellm.types.files import get_file_extension_from_mime_type
from litellm.types.llms.anthropic import *
@@ -3324,7 +3325,7 @@ def _load_image_from_url(image_url):
try:
# Send a GET request to the image URL
client = HTTPHandler(concurrent_limit=1)
response = client.get(image_url)
response = safe_get(client, image_url)
response.raise_for_status() # Raise an exception for HTTP errors
# Check the response's content type to ensure it is an image
@@ -3562,7 +3563,7 @@ class BedrockImageProcessor:
params={"concurrent_limit": 1},
)
# Send a GET request to the image URL
response = await client.get(image_url, follow_redirects=True)
response = await async_safe_get(client, image_url)
response.raise_for_status() # Raise an exception for HTTP errors
return BedrockImageProcessor._post_call_image_processing(
@@ -3577,7 +3578,7 @@ class BedrockImageProcessor:
try:
client = HTTPHandler(concurrent_limit=1)
# Send a GET request to the image URL
response = client.get(image_url, follow_redirects=True)
response = safe_get(client, image_url)
response.raise_for_status() # Raise an exception for HTTP errors
return BedrockImageProcessor._post_call_image_processing(
+9 -1
View File
@@ -23,6 +23,7 @@ from litellm.constants import (
DEFAULT_IMAGE_HEIGHT,
DEFAULT_IMAGE_TOKEN_COUNT,
DEFAULT_IMAGE_WIDTH,
MAX_IMAGE_URL_DOWNLOAD_SIZE_MB,
MAX_LONG_SIDE_FOR_IMAGE_HIGH_RES,
MAX_SHORT_SIDE_FOR_IMAGE_HIGH_RES,
MAX_TILE_HEIGHT,
@@ -215,7 +216,14 @@ def get_image_dimensions(
try:
client = _get_httpx_client()
response = safe_get(client, data)
img_data = response.read()
max_bytes = int(MAX_IMAGE_URL_DOWNLOAD_SIZE_MB * 1024 * 1024)
content_length = response.headers.get("Content-Length")
if content_length is not None and int(content_length) > max_bytes:
pass # skip download; img_data stays None
else:
body = response.read()
if len(body) <= max_bytes:
img_data = body
except Exception:
pass
if img_data is None:
@@ -106,6 +106,44 @@ class LiteLLMMessagesToCompletionTransformationHandler:
updated_reasoning_effort["summary"] = effective_summary
completion_kwargs["reasoning_effort"] = updated_reasoning_effort
@staticmethod
def _normalize_reasoning_effort(
completion_kwargs: Dict[str, Any],
) -> None:
"""
Normalize reasoning_effort values based on target model capabilities.
Handles both string ("max") and dict ({"effort": "max", "summary": ...})
formats. Uses model registry to check supports_xhigh/supports_minimal.
"""
from litellm.llms.anthropic.experimental_pass_through.utils import (
normalize_reasoning_effort_value,
)
reasoning_effort = completion_kwargs.get("reasoning_effort")
if reasoning_effort is None:
return
model = cast(str, completion_kwargs.get("model", ""))
custom_llm_provider = completion_kwargs.get("custom_llm_provider")
if isinstance(reasoning_effort, str):
normalized = normalize_reasoning_effort_value(
reasoning_effort, model=model, custom_llm_provider=custom_llm_provider
)
if normalized != reasoning_effort:
completion_kwargs["reasoning_effort"] = normalized
elif isinstance(reasoning_effort, dict) and "effort" in reasoning_effort:
effort = reasoning_effort["effort"]
normalized = normalize_reasoning_effort_value(
effort, model=model, custom_llm_provider=custom_llm_provider
)
if normalized != effort:
completion_kwargs["reasoning_effort"] = {
**reasoning_effort,
"effort": normalized,
}
@staticmethod
def _prepare_completion_kwargs(
*,
@@ -163,6 +201,12 @@ class LiteLLMMessagesToCompletionTransformationHandler:
if output_format:
request_data["output_format"] = output_format
# Extract output_config from extra_kwargs so the translator can use it
# (e.g. output_config.effort for adaptive thinking → reasoning_effort)
extra_kwargs = extra_kwargs or {}
if "output_config" in extra_kwargs:
request_data["output_config"] = extra_kwargs["output_config"]
(
openai_request,
tool_name_mapping,
@@ -202,6 +246,14 @@ class LiteLLMMessagesToCompletionTransformationHandler:
):
completion_kwargs[key] = value
# Normalize reasoning_effort based on model capabilities
# (e.g. "max" → "xhigh"/"high", "minimal" → "low" if unsupported)
# Must run BEFORE _route_openai_thinking, which prepends "responses/"
# to the model name and would break get_model_info() lookups.
LiteLLMMessagesToCompletionTransformationHandler._normalize_reasoning_effort(
completion_kwargs
)
LiteLLMMessagesToCompletionTransformationHandler._route_openai_thinking_to_responses_api_if_needed(
completion_kwargs,
thinking=thinking,
@@ -317,6 +317,7 @@ class LiteLLMAnthropicMessagesAdapter:
"tools",
"thinking",
"output_format",
"output_config",
]
def _is_web_search_tool(self, tool: Dict[str, Any]) -> bool:
@@ -694,6 +695,11 @@ class LiteLLMAnthropicMessagesAdapter:
return "low"
else:
return "minimal"
elif thinking_type == "adaptive":
# Adaptive thinking: effort is controlled by output_config.effort,
# not budget_tokens. Return a default; caller should override with
# output_config.effort when available.
return "medium"
return None
@@ -776,6 +782,8 @@ class LiteLLMAnthropicMessagesAdapter:
return ChatCompletionToolChoiceObjectParam(
type="function", function=tc_function_param
)
elif tool_choice["type"] == "none":
return "none"
else:
raise ValueError(
"Incompatible tool choice param submitted - {}".format(tool_choice)
@@ -1041,6 +1049,12 @@ class LiteLLMAnthropicMessagesAdapter:
if not reasoning_effort:
return
# For adaptive thinking, override with output_config.effort if available
if isinstance(thinking, dict) and thinking.get("type") == "adaptive":
output_config = anthropic_message_request.get("output_config")
if isinstance(output_config, dict) and output_config.get("effort"):
reasoning_effort = output_config["effort"]
summary = thinking.get("summary") if isinstance(thinking, dict) else None
auto_summary = is_reasoning_auto_summary_enabled()
if summary:
@@ -24,6 +24,8 @@ from litellm.types.llms.anthropic_messages.anthropic_response import (
from litellm.types.router import GenericLiteLLMParams
from litellm.utils import ProviderConfigManager, client
from ..utils import is_reasoning_auto_summary_enabled
from ..adapters.handler import LiteLLMMessagesToCompletionTransformationHandler
from ..responses_adapters.handler import LiteLLMMessagesToResponsesAPIHandler
from .interceptors import get_messages_interceptors
@@ -441,6 +443,17 @@ def anthropic_messages_handler(
params=local_vars
)
)
if is_reasoning_auto_summary_enabled():
thinking_param = anthropic_messages_optional_request_params.get("thinking")
if (
isinstance(thinking_param, dict)
and thinking_param.get("type") != "disabled"
):
anthropic_messages_optional_request_params["thinking"] = {
**thinking_param,
"display": "summarized",
}
return base_llm_http_handler.anthropic_messages_handler(
model=model,
messages=messages,
@@ -72,6 +72,23 @@ def _build_responses_kwargs(
anthropic_request = AnthropicMessagesRequest(**request_data) # type: ignore[typeddict-item]
responses_kwargs = _ADAPTER.translate_request(anthropic_request)
# Normalize reasoning effort based on model capabilities
# (e.g. "max" → "xhigh"/"high", "minimal" → "low" if unsupported)
reasoning = responses_kwargs.get("reasoning")
if isinstance(reasoning, dict) and "effort" in reasoning:
from litellm.llms.anthropic.experimental_pass_through.utils import (
normalize_reasoning_effort_value,
)
effort = reasoning["effort"]
normalized = normalize_reasoning_effort_value(
effort,
model=model,
custom_llm_provider=(extra_kwargs or {}).get("custom_llm_provider"),
)
if normalized != effort:
responses_kwargs["reasoning"] = {**reasoning, "effort": normalized}
if stream:
responses_kwargs["stream"] = True
@@ -251,25 +251,41 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
@staticmethod
def translate_thinking_to_reasoning(
thinking: Dict[str, Any]
thinking: Dict[str, Any],
output_config: Optional[Dict[str, Any]] = None,
) -> Optional[Dict[str, Any]]:
"""
Convert Anthropic thinking param to Responses API reasoning param.
thinking.budget_tokens maps to reasoning effort:
>= 10000 -> high, >= 5000 -> medium, >= 2000 -> low, < 2000 -> minimal
For adaptive thinking, uses output_config.effort if available,
otherwise defaults to medium.
"""
if not isinstance(thinking, dict) or thinking.get("type") != "enabled":
if not isinstance(thinking, dict):
return None
budget = thinking.get("budget_tokens", 0)
if budget >= 10000:
effort = "high"
elif budget >= 5000:
thinking_type = thinking.get("type")
if thinking_type == "adaptive":
# Use output_config.effort if available
effort = "medium"
elif budget >= 2000:
effort = "low"
if isinstance(output_config, dict) and output_config.get("effort"):
effort = output_config["effort"]
elif thinking_type == "enabled":
budget = thinking.get("budget_tokens", 0)
if budget >= 10000:
effort = "high"
elif budget >= 5000:
effort = "medium"
elif budget >= 2000:
effort = "low"
else:
effort = "minimal"
else:
effort = "minimal"
return None
auto_summary = is_reasoning_auto_summary_enabled()
result: Dict[str, Any] = {"effort": effort}
summary = thinking.get("summary")
@@ -346,7 +362,11 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
# thinking -> reasoning
thinking = anthropic_request.get("thinking")
if isinstance(thinking, dict):
reasoning = self.translate_thinking_to_reasoning(thinking)
output_config = anthropic_request.get("output_config")
reasoning = self.translate_thinking_to_reasoning(
thinking,
output_config=cast(Optional[Dict[str, Any]], output_config),
)
if reasoning:
responses_kwargs["reasoning"] = reasoning
@@ -1,6 +1,8 @@
import os
from typing import Optional
import litellm
from litellm.types.utils import ModelInfo
def is_reasoning_auto_summary_enabled() -> bool:
@@ -9,3 +11,47 @@ def is_reasoning_auto_summary_enabled() -> bool:
litellm.reasoning_auto_summary
or os.getenv("LITELLM_REASONING_AUTO_SUMMARY", "false").lower() == "true"
)
def normalize_reasoning_effort_value(
effort: str,
model: str,
custom_llm_provider: Optional[str] = None,
) -> str:
"""
Normalize a reasoning effort value based on model capabilities.
Degradation chains:
- "max" max / xhigh / high
- "xhigh" xhigh / high
- "minimal" minimal / low
- other values pass through unchanged
"""
if effort not in ("max", "xhigh", "minimal"):
return effort
from litellm.utils import get_model_info
model_info: Optional[ModelInfo] = None
try:
model_info = get_model_info(
model=model, custom_llm_provider=custom_llm_provider
)
except Exception:
model_info = None
if effort == "max":
if model_info and model_info.get("supports_max_reasoning_effort"):
return "max"
if model_info and model_info.get("supports_xhigh_reasoning_effort"):
return "xhigh"
return "high"
elif effort == "xhigh":
if model_info and model_info.get("supports_xhigh_reasoning_effort"):
return "xhigh"
return "high"
elif effort == "minimal":
if model_info and model_info.get("supports_minimal_reasoning_effort"):
return "minimal"
return "low"
return "medium"
@@ -40,9 +40,22 @@ class AzureOpenAIGPT5Config(AzureOpenAIConfig, OpenAIGPT5Config):
Accepts both explicit gpt-5 model names and the ``gpt5_series/`` prefix
used for manual routing.
"""
# gpt-5-chat* is a chat model and shouldn't go through GPT-5 reasoning restrictions.
# The gpt-5-chat* family (gpt-5-chat, gpt-5-chat-latest, gpt-5-chat-2025-08-07,
# …) are regular chat models: they support temperature and tool_choice but NOT
# reasoning_effort. They must NOT be routed through the GPT-5 reasoning path.
#
# Versioned chat models such as gpt-5.3-chat and gpt-5.1-chat ARE reasoning
# models and must stay on the GPT-5 path. The distinguishing feature is that
# the gpt-5-chat family has a literal "-chat" immediately after "gpt-5"
# (i.e. "gpt-5-chat…"), while versioned chat models interpose a minor version
# number (i.e. "gpt-5.<digit>-chat").
#
# Using a startswith("gpt-5-chat") prefix check on the normalized name (rather
# than a substring check) makes this boundary explicit and avoids any ambiguity
# if future model names coincidentally contain "gpt-5-chat" as an interior run.
_normalized = model.split("/")[-1] # strip provider prefix, e.g. "azure/"
return (
"gpt-5" in model and "gpt-5-chat" not in model
"gpt-5" in model and not _normalized.startswith("gpt-5-chat")
) or "gpt5_series" in model
def get_supported_openai_params(self, model: str) -> List[str]:
@@ -14,6 +14,8 @@ class AzureImageEditConfig(OpenAIImageEditConfig):
headers: dict,
model: str,
api_key: Optional[str] = None,
litellm_params: Optional[dict] = None,
api_base: Optional[str] = None,
) -> dict:
api_key = (
api_key
@@ -65,6 +65,8 @@ class AzureFoundryFlux2ImageEditConfig(OpenAIImageEditConfig):
headers: dict,
model: str,
api_key: Optional[str] = None,
litellm_params: Optional[dict] = None,
api_base: Optional[str] = None,
) -> dict:
"""
Validate Azure AI Foundry environment and set up authentication
@@ -25,6 +25,8 @@ class AzureFoundryFluxImageEditConfig(OpenAIImageEditConfig):
headers: dict,
model: str,
api_key: Optional[str] = None,
litellm_params: Optional[dict] = None,
api_base: Optional[str] = None,
) -> dict:
"""
Validate Azure AI Foundry environment and set up authentication
@@ -67,6 +67,8 @@ class BaseImageEditConfig(ABC):
headers: dict,
model: str,
api_key: Optional[str] = None,
litellm_params: Optional[dict] = None,
api_base: Optional[str] = None,
) -> dict:
return {}
@@ -1,4 +1,5 @@
import os
import re
import time
from typing import Any, Dict, List, Literal, Optional, Union, cast
@@ -294,7 +295,8 @@ class BedrockBatchesConfig(BaseAWSLLM, BaseBatchesConfig):
raise ValueError(f"Invalid ARN format: {batch_id}")
region = arn_parts[3]
# arn_parts[5] contains "model-invocation-job/{jobId}"
if not re.match(r"^[a-z][a-z0-9-]*$", region):
raise ValueError(f"Invalid region in ARN: {batch_id}")
# Build the endpoint URL for GetModelInvocationJob
# AWS API format: GET /model-invocation-job/{jobIdentifier}
@@ -483,6 +483,8 @@ class BedrockAmazonNovaCanvasImageEditConfig(BaseImageEditConfig):
headers: dict,
model: str,
api_key: Optional[str] = None,
litellm_params: Optional[dict] = None,
api_base: Optional[str] = None,
) -> dict:
if headers is None:
headers = {}
@@ -372,6 +372,8 @@ class BedrockStabilityImageEditConfig(BaseImageEditConfig):
headers: dict,
model: str,
api_key: Optional[str] = None,
litellm_params: Optional[dict] = None,
api_base: Optional[str] = None,
) -> dict:
"""
Validate environment for Bedrock Stability image edit.
@@ -14,7 +14,9 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union
import httpx
from httpx._types import RequestFiles
import litellm
from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH
from litellm.litellm_core_utils.url_utils import safe_get
from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig
from litellm.secret_managers.main import get_secret_str
from litellm.types.images.main import ImageEditOptionalRequestParams
@@ -123,6 +125,8 @@ class BlackForestLabsImageEditConfig(BaseImageEditConfig):
headers: dict,
model: str,
api_key: Optional[str] = None,
litellm_params: Optional[dict] = None,
api_base: Optional[str] = None,
) -> dict:
"""
Validate environment and set up headers for Black Forest Labs.
@@ -206,14 +210,14 @@ class BlackForestLabsImageEditConfig(BaseImageEditConfig):
)
elif isinstance(image, str):
if image.startswith(("http://", "https://")):
# Download image from URL
response = httpx.get(image, timeout=60.0)
response = safe_get(litellm.module_level_client, image, timeout=60.0)
response.raise_for_status()
return response.content
else:
# Assume it's a file path
with open(image, "rb") as f:
return f.read()
raise ValueError(
"Unsupported image input: plain string values that are not URLs are not accepted. "
"Provide image bytes or a file-like object."
)
elif hasattr(image, "read"):
# File-like object
pos = getattr(image, "tell", lambda: 0)()
@@ -5515,6 +5515,8 @@ class BaseLLMHTTPHandler:
api_key=litellm_params.api_key,
headers=image_edit_optional_request_params.get("extra_headers", {}) or {},
model=model,
litellm_params=dict(litellm_params),
api_base=litellm_params.api_base,
)
if extra_headers:
@@ -5611,6 +5613,8 @@ class BaseLLMHTTPHandler:
api_key=litellm_params.api_key,
headers=image_edit_optional_request_params.get("extra_headers", {}) or {},
model=model,
litellm_params=dict(litellm_params),
api_base=litellm_params.api_base,
)
if extra_headers:
@@ -0,0 +1,11 @@
from litellm.llms.base_llm.image_generation.transformation import (
BaseImageGenerationConfig,
)
from .transformation import DashScopeImageGenerationConfig
__all__ = ["DashScopeImageGenerationConfig"]
def get_dashscope_image_generation_config(model: str) -> BaseImageGenerationConfig:
return DashScopeImageGenerationConfig()
@@ -0,0 +1,204 @@
"""
DashScope Image Generation Configuration
Handles transformation between OpenAI-compatible format and DashScope multimodal-generation API.
API endpoint: POST https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation
Request format:
{
"model": "qwen-image-2.0-pro",
"input": {
"messages": [{"role": "user", "content": [{"text": "<prompt>"}]}]
},
"parameters": {"size": "1024*1024", ...}
}
Response format:
{
"output": {
"choices": [{"message": {"content": [{"image": "<url>"}]}}]
},
"usage": {"input_tokens": 0, "output_tokens": 0, "width": 1024, "height": 1024, "image_count": 1}
}
"""
from typing import TYPE_CHECKING, Any, List, Optional
import httpx
from litellm.llms.base_llm.image_generation.transformation import (
BaseImageGenerationConfig,
)
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.openai import (
AllMessageValues,
OpenAIImageGenerationOptionalParams,
)
from litellm.types.utils import ImageObject, ImageResponse
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
LiteLLMLoggingObj = _LiteLLMLoggingObj
else:
LiteLLMLoggingObj = Any
DEFAULT_API_BASE = "https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation"
# Maps OpenAI size strings (WxH) to DashScope size strings (W*H)
OPENAI_TO_DASHSCOPE_SIZE: dict = {
"256x256": "256*256",
"512x512": "512*512",
"1024x1024": "1024*1024",
"1792x1024": "1792*1024",
"1024x1792": "1024*1792",
"2048x2048": "2048*2048",
}
class DashScopeImageGenerationConfig(BaseImageGenerationConfig):
"""
Configuration for DashScope image generation (qwen-image-2.0, qwen-image-2.0-pro).
"""
def get_supported_openai_params(
self, model: str
) -> List[OpenAIImageGenerationOptionalParams]:
return ["n", "size"]
def map_openai_params(
self,
non_default_params: dict,
optional_params: dict,
model: str,
drop_params: bool,
) -> dict:
supported_params = self.get_supported_openai_params(model)
mapped: dict = {}
for k, v in non_default_params.items():
if k in optional_params:
continue
if k not in supported_params:
continue
if k == "size":
# Convert "WxH" → "W*H"
mapped["size"] = OPENAI_TO_DASHSCOPE_SIZE.get(v, v.replace("x", "*"))
elif k == "n":
mapped["image_count"] = v
return mapped
def get_complete_url(
self,
api_base: Optional[str],
api_key: Optional[str],
model: str,
optional_params: dict,
litellm_params: dict,
stream: Optional[bool] = None,
) -> str:
return (
api_base or get_secret_str("DASHSCOPE_API_BASE_IMAGE") or DEFAULT_API_BASE
)
def validate_environment(
self,
headers: dict,
model: str,
messages: List[AllMessageValues],
optional_params: dict,
litellm_params: dict,
api_key: Optional[str] = None,
api_base: Optional[str] = None,
) -> dict:
final_api_key = api_key or get_secret_str("DASHSCOPE_API_KEY")
if not final_api_key:
raise ValueError("DASHSCOPE_API_KEY is not set")
headers["Authorization"] = f"Bearer {final_api_key}"
headers["Content-Type"] = "application/json"
return headers
def transform_image_generation_request(
self,
model: str,
prompt: str,
optional_params: dict,
litellm_params: dict,
headers: dict,
) -> dict:
"""
Transform OpenAI-style image generation request to DashScope multimodal-generation format.
"""
parameters: dict = {}
for k, v in optional_params.items():
parameters[k] = v
return {
"model": model,
"input": {
"messages": [
{
"role": "user",
"content": [{"text": prompt}],
}
]
},
"parameters": parameters,
}
def transform_image_generation_response(
self,
model: str,
raw_response: httpx.Response,
model_response: ImageResponse,
logging_obj: LiteLLMLoggingObj,
request_data: dict,
optional_params: dict,
litellm_params: dict,
encoding: Any,
api_key: Optional[str] = None,
json_mode: Optional[bool] = None,
) -> ImageResponse:
"""
Transform DashScope response to litellm ImageResponse.
DashScope response: output.choices[0].message.content[0].image
OpenAI response: data[0].url
"""
if raw_response.status_code != 200:
raise self.get_error_class(
error_message=raw_response.text,
status_code=raw_response.status_code,
headers=raw_response.headers,
)
try:
response_data = raw_response.json()
except Exception as e:
raise self.get_error_class(
error_message=f"Failed to parse DashScope image generation response: {e}",
status_code=raw_response.status_code,
headers=raw_response.headers,
)
# DashScope can return API-level errors in a 200 response body.
# Example: {"code": "InvalidParameter", "message": "Size not supported"}
if "code" in response_data and "output" not in response_data:
raise self.get_error_class(
error_message=str(response_data.get("message", response_data)),
status_code=raw_response.status_code,
headers=raw_response.headers,
)
if not model_response.data:
model_response.data = []
choices = response_data.get("output", {}).get("choices", [])
for choice in choices:
content_list = choice.get("message", {}).get("content", [])
for content_item in content_list:
image_url = content_item.get("image")
if image_url:
model_response.data.append(ImageObject(url=image_url))
return model_response
@@ -54,6 +54,8 @@ class GeminiImageEditConfig(BaseImageEditConfig):
headers: dict,
model: str,
api_key: Optional[str] = None,
litellm_params: Optional[dict] = None,
api_base: Optional[str] = None,
) -> dict:
final_api_key: Optional[str] = api_key or get_secret_str("GEMINI_API_KEY")
if not final_api_key:
@@ -8,7 +8,12 @@ class LiteLLMProxyImageEditConfig(OpenAIImageEditConfig):
"""Configuration for image edit requests routed through LiteLLM Proxy."""
def validate_environment(
self, headers: dict, model: str, api_key: Optional[str] = None
self,
headers: dict,
model: str,
api_key: Optional[str] = None,
litellm_params: Optional[dict] = None,
api_base: Optional[str] = None,
) -> dict:
api_key = api_key or get_secret_str("LITELLM_PROXY_API_KEY")
headers.update({"Authorization": f"Bearer {api_key}"})
@@ -53,9 +53,21 @@ class OpenAIGPT5Config(OpenAIGPTConfig):
@classmethod
def is_model_gpt_5_model(cls, model: str) -> bool:
# gpt-5-chat* behaves like a regular chat model (supports temperature, etc.)
# Don't route it through GPT-5 reasoning-specific parameter restrictions.
return "gpt-5" in model and "gpt-5-chat" not in model
# The gpt-5-chat* family (gpt-5-chat, gpt-5-chat-latest, gpt-5-chat-2025-08-07,
# …) are regular chat models: they support temperature and tool_choice but NOT
# reasoning_effort. They must NOT be routed through the GPT-5 reasoning path.
#
# Versioned chat models such as gpt-5.3-chat and gpt-5.1-chat ARE reasoning
# models and must stay on the GPT-5 path. The distinguishing feature is that
# the gpt-5-chat family has a literal "-chat" immediately after "gpt-5"
# (i.e. "gpt-5-chat…"), while versioned chat models interpose a minor version
# number (i.e. "gpt-5.<digit>-chat").
#
# Using a startswith("gpt-5-chat") prefix check on the normalized name (rather
# than a substring check) makes this boundary explicit and avoids any ambiguity
# if future model names coincidentally contain "gpt-5-chat" as an interior run.
_normalized = model.split("/")[-1] # strip provider prefix, e.g. "openai/"
return "gpt-5" in model and not _normalized.startswith("gpt-5-chat")
@classmethod
def is_model_gpt_5_search_model(cls, model: str) -> bool:
@@ -165,6 +165,8 @@ class OpenAIImageEditConfig(BaseImageEditConfig):
headers: dict,
model: str,
api_key: Optional[str] = None,
litellm_params: Optional[dict] = None,
api_base: Optional[str] = None,
) -> dict:
api_key = (
api_key
@@ -116,6 +116,8 @@ class OpenRouterImageEditConfig(BaseImageEditConfig):
headers: dict,
model: str,
api_key: Optional[str] = None,
litellm_params: Optional[dict] = None,
api_base: Optional[str] = None,
) -> dict:
api_key = api_key or litellm.api_key or get_secret_str("OPENROUTER_API_KEY")
if not api_key:
+1 -30
View File
@@ -8,9 +8,8 @@ More information on our website: https://endpoints.ai.cloud.ovh.net
from typing import Optional, Union, List
import httpx
from litellm.utils import ModelResponseStream, _get_model_info_helper
from litellm.utils import ModelResponseStream
from litellm.llms.openai.chat.gpt_transformation import OpenAIGPTConfig
from litellm._logging import verbose_logger
from litellm.llms.ovhcloud.utils import OVHCloudException
from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator
from litellm.llms.base_llm.chat.transformation import BaseLLMException
@@ -22,34 +21,6 @@ class OVHCloudChatConfig(OpenAIGPTConfig):
def custom_llm_provider(self) -> Optional[str]:
return "ovhcloud"
def get_supported_openai_params(self, model: str) -> list:
"""
Details about function calling support can be found here:
https://help.ovhcloud.com/csm/en-gb-public-cloud-ai-endpoints-function-calling?id=kb_article_view&sysparm_article=KB0071907
"""
supports_function_calling: Optional[bool] = None
try:
model_info = _get_model_info_helper(model, custom_llm_provider="ovhcloud")
supports_function_calling = model_info.get(
"supports_function_calling", None
)
if supports_function_calling is None:
supports_function_calling = False
except Exception as e:
verbose_logger.debug(f"Error getting supported OpenAI params: {e}")
supports_function_calling = False
optional_params = super().get_supported_openai_params(model)
if supports_function_calling is not True:
verbose_logger.debug(
"You can see our models supporting function_calling in our catalog: https://endpoints.ai.cloud.ovh.net/catalog "
)
optional_params.remove("tools")
optional_params.remove("tool_choice")
optional_params.remove("function_call")
optional_params.remove("response_format")
return optional_params
def get_complete_url(
self,
api_base: Optional[str],
@@ -81,6 +81,8 @@ class RecraftImageEditConfig(BaseImageEditConfig):
headers: dict,
model: str,
api_key: Optional[str] = None,
litellm_params: Optional[dict] = None,
api_base: Optional[str] = None,
) -> dict:
final_api_key: Optional[str] = api_key or get_secret_str("RECRAFT_API_KEY")
if not final_api_key:
@@ -1,3 +1,4 @@
import re
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union
import httpx
@@ -66,6 +67,8 @@ class S3VectorsVectorStoreConfig(BaseVectorStoreConfig, BaseAWSLLM):
aws_region_name = litellm_params.get("aws_region_name")
if not aws_region_name:
raise ValueError("aws_region_name is required for S3 Vectors")
if not re.match(r"^[a-z][a-z0-9-]*$", aws_region_name):
raise ValueError("Invalid aws_region_name format")
return f"https://s3vectors.{aws_region_name}.api.aws"
def transform_search_vector_store_request(
+3
View File
@@ -1,3 +1,4 @@
import re
from typing import TYPE_CHECKING, Any, List, Optional, Tuple
from litellm.secret_managers.main import get_secret_str
@@ -61,6 +62,8 @@ class SnowflakeBaseConfig:
account_id = get_secret_str("SNOWFLAKE_ACCOUNT_ID")
if account_id is None:
raise ValueError("Missing snowflake account_id")
if not re.match(r"^[a-zA-Z0-9_-]+$", account_id):
raise ValueError("Invalid account_id format")
api_base = f"https://{account_id}.snowflakecomputing.com/api/v2"
api_base = api_base.rstrip("/")
@@ -149,6 +149,8 @@ class StabilityImageEditConfig(BaseImageEditConfig):
headers: dict,
model: str,
api_key: Optional[str] = None,
litellm_params: Optional[dict] = None,
api_base: Optional[str] = None,
) -> dict:
"""
Validate environment and set up headers for Stability AI.
+11 -2
View File
@@ -229,11 +229,20 @@ def get_vertex_base_url(
) -> str:
"""
Get the base URL for Vertex AI API calls.
- ``global`` uses the global control plane host.
- Multi-region geographies (e.g. ``us``, ``eu``) use ``aiplatform.{geo}.rep.googleapis.com``.
- Regional locations (e.g. ``us-central1``) use ``{region}-aiplatform.googleapis.com``.
"""
if vertex_location == "global":
return "https://aiplatform.googleapis.com"
else:
return f"https://{vertex_location}-aiplatform.googleapis.com"
if vertex_location is None:
raise ValueError("vertex_location is required")
if not re.match(r"^[a-z][a-z0-9-]*$", vertex_location):
raise ValueError("Invalid vertex_location format")
if "-" not in vertex_location:
return f"https://aiplatform.{vertex_location}.rep.googleapis.com"
return f"https://{vertex_location}-aiplatform.googleapis.com"
def _get_embedding_url(
+14 -12
View File
@@ -132,26 +132,28 @@ def _extract_max_media_resolution_from_messages(
return max_resolution
def _apply_gemini_3_metadata(
def _apply_gemini_metadata(
part: PartType,
model: Optional[str],
media_resolution_enum: Optional[Dict[str, str]],
video_metadata: Optional[Dict[str, Any]],
) -> PartType:
"""
Apply the unique media_resolution and video_metadata parameters of Gemini 3+
Apply media_resolution and video_metadata parameters to a Gemini part.
- Per-part media_resolution: Gemini 3+ only (2.x uses generation_config global).
- video_metadata (fps, startOffset, endOffset): all Gemini models (1.x, 2.x, 3+).
"""
if model is None:
return part
from .vertex_and_google_ai_studio_gemini import VertexGeminiConfig
if not VertexGeminiConfig._is_gemini_3_or_newer(model):
return part
part_dict = dict(part)
if media_resolution_enum is not None:
if media_resolution_enum is not None and VertexGeminiConfig._is_gemini_3_or_newer(
model
):
part_dict["media_resolution"] = media_resolution_enum
if video_metadata is not None:
@@ -206,7 +208,7 @@ def _process_gemini_media(
mime_type = format
file_data = FileDataType(mime_type=mime_type, file_uri=image_url)
part: PartType = {"file_data": file_data}
return _apply_gemini_3_metadata(
return _apply_gemini_metadata(
part, model, media_resolution_enum, video_metadata
)
elif (
@@ -216,14 +218,14 @@ def _process_gemini_media(
):
file_data = FileDataType(mime_type=image_type, file_uri=image_url)
part = {"file_data": file_data}
return _apply_gemini_3_metadata(
return _apply_gemini_metadata(
part, model, media_resolution_enum, video_metadata
)
elif "http://" in image_url or "https://" in image_url or "base64" in image_url:
image = convert_to_anthropic_image_obj(image_url, format=format)
_blob: BlobType = {"data": image["data"], "mime_type": image["media_type"]}
part = {"inline_data": cast(BlobType, _blob)}
return _apply_gemini_3_metadata(
return _apply_gemini_metadata(
part, model, media_resolution_enum, video_metadata
)
raise Exception("Invalid image received - {}".format(image_url))
@@ -733,9 +735,9 @@ def _transform_request_body( # noqa: PLR0915
**filtered_params
)
# For Gemini 2.x models, add media_resolution to generation_config (global)
# Gemini 3+ supports per-part media_resolution, but 2.x only supports global
# Gemini 1.x does not support mediaResolution at all
# For Gemini 2.x models, also add media_resolution to generation_config (global)
# as a fallback, since some 2.x versions may not support per-part media_resolution.
# Gemini 1.x does not support mediaResolution at all.
if "gemini-2" in model:
max_media_resolution = _extract_max_media_resolution_from_messages(messages)
if max_media_resolution:
@@ -103,10 +103,24 @@ class VertexAIImagenImageEditConfig(BaseImageEditConfig, VertexLLM):
headers: dict,
model: str,
api_key: Optional[str] = None,
litellm_params: Optional[dict] = None,
api_base: Optional[str] = None,
) -> dict:
headers = headers or {}
vertex_project = self._resolve_vertex_project()
vertex_credentials = self._resolve_vertex_credentials()
litellm_params = litellm_params or {}
_api_base = litellm_params.get("api_base") or api_base
if _api_base is not None:
return headers
vertex_project = (
self.safe_get_vertex_ai_project(litellm_params)
or self._resolve_vertex_project()
)
vertex_credentials = (
self.safe_get_vertex_ai_credentials(litellm_params)
or self._resolve_vertex_credentials()
)
access_token, _ = self._ensure_access_token(
credentials=vertex_credentials,
project_id=vertex_project,
@@ -123,8 +137,14 @@ class VertexAIImagenImageEditConfig(BaseImageEditConfig, VertexLLM):
"""
Get the complete URL for Vertex AI Imagen predict API
"""
vertex_project = self._resolve_vertex_project()
vertex_location = self._resolve_vertex_location()
vertex_project = (
self.safe_get_vertex_ai_project(litellm_params)
or self._resolve_vertex_project()
)
vertex_location = (
self.safe_get_vertex_ai_location(litellm_params)
or self._resolve_vertex_location()
)
if not vertex_project or not vertex_location:
raise ValueError(
@@ -348,13 +368,16 @@ class VertexAIImagenImageEditConfig(BaseImageEditConfig, VertexLLM):
if stream_pos is not None:
image.seek(stream_pos)
return data
if isinstance(image, (str, Path)):
path_obj = Path(image)
if not path_obj.exists():
raise ValueError(
f"Mask/image path does not exist for Vertex AI Imagen image edit: {path_obj}"
)
return path_obj.read_bytes()
if isinstance(image, str):
raise ValueError(
"Unsupported image input: plain string values are not accepted for "
"Vertex AI Imagen image edit. Provide image bytes or a file-like object."
)
if isinstance(image, Path):
raise ValueError(
"Unsupported image input: filesystem paths are not accepted for "
"Vertex AI Imagen image edit. Provide image bytes or a file-like object."
)
if hasattr(image, "read"):
data = image.read()
if isinstance(data, str):
@@ -1006,7 +1006,8 @@
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true,
"supports_max_reasoning_effort": true
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"global.anthropic.claude-opus-4-6-v1": {
"cache_creation_input_token_cost": 6.25e-06,
@@ -1034,7 +1035,8 @@
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true,
"supports_max_reasoning_effort": true
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"us.anthropic.claude-opus-4-6-v1": {
"cache_creation_input_token_cost": 6.875e-06,
@@ -1062,7 +1064,8 @@
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true,
"supports_max_reasoning_effort": true
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"eu.anthropic.claude-opus-4-6-v1": {
"cache_creation_input_token_cost": 6.875e-06,
@@ -1090,7 +1093,8 @@
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true,
"supports_max_reasoning_effort": true
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"au.anthropic.claude-opus-4-6-v1": {
"cache_creation_input_token_cost": 6.875e-06,
@@ -1118,7 +1122,8 @@
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true,
"supports_max_reasoning_effort": true
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"anthropic.claude-opus-4-7": {
"cache_creation_input_token_cost": 6.25e-06,
@@ -1146,7 +1151,9 @@
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true
"supports_native_structured_output": true,
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"global.anthropic.claude-opus-4-7": {
"cache_creation_input_token_cost": 6.25e-06,
@@ -1174,7 +1181,9 @@
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true
"supports_native_structured_output": true,
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"us.anthropic.claude-opus-4-7": {
"cache_creation_input_token_cost": 6.875e-06,
@@ -1202,7 +1211,9 @@
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true
"supports_native_structured_output": true,
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"eu.anthropic.claude-opus-4-7": {
"cache_creation_input_token_cost": 6.875e-06,
@@ -1230,7 +1241,9 @@
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true
"supports_native_structured_output": true,
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"au.anthropic.claude-opus-4-7": {
"cache_creation_input_token_cost": 6.875e-06,
@@ -1258,7 +1271,9 @@
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true
"supports_native_structured_output": true,
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"anthropic.claude-sonnet-4-6": {
"cache_creation_input_token_cost": 3.75e-06,
@@ -1285,7 +1300,8 @@
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true
"supports_native_structured_output": true,
"supports_minimal_reasoning_effort": true
},
"global.anthropic.claude-sonnet-4-6": {
"cache_creation_input_token_cost": 3.75e-06,
@@ -1312,7 +1328,8 @@
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true
"supports_native_structured_output": true,
"supports_minimal_reasoning_effort": true
},
"us.anthropic.claude-sonnet-4-6": {
"cache_creation_input_token_cost": 4.125e-06,
@@ -1339,7 +1356,8 @@
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true
"supports_native_structured_output": true,
"supports_minimal_reasoning_effort": true
},
"eu.anthropic.claude-sonnet-4-6": {
"cache_creation_input_token_cost": 4.125e-06,
@@ -1366,7 +1384,8 @@
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true
"supports_native_structured_output": true,
"supports_minimal_reasoning_effort": true
},
"au.anthropic.claude-sonnet-4-6": {
"cache_creation_input_token_cost": 4.125e-06,
@@ -1393,7 +1412,8 @@
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true
"supports_native_structured_output": true,
"supports_minimal_reasoning_effort": true
},
"anthropic.claude-sonnet-4-20250514-v1:0": {
"cache_creation_input_token_cost": 3.75e-06,
@@ -1911,7 +1931,8 @@
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 159,
"supports_max_reasoning_effort": true
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"azure_ai/claude-opus-4-7": {
"input_cost_per_token": 5e-06,
@@ -1939,7 +1960,9 @@
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"tool_use_system_prompt_tokens": 159
"tool_use_system_prompt_tokens": 159,
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"azure_ai/claude-opus-4-1": {
"cache_creation_input_token_cost": 1.875e-05,
@@ -2003,7 +2026,8 @@
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346
"tool_use_system_prompt_tokens": 346,
"supports_minimal_reasoning_effort": true
},
"azure/computer-use-preview": {
"input_cost_per_token": 3e-06,
@@ -8909,7 +8933,8 @@
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346
"tool_use_system_prompt_tokens": 346,
"supports_minimal_reasoning_effort": true
},
"claude-sonnet-4-5-20250929-v1:0": {
"cache_creation_input_token_cost": 3.75e-06,
@@ -9103,7 +9128,8 @@
"us": 1.1,
"fast": 6.0
},
"supports_max_reasoning_effort": true
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"claude-opus-4-6-20260205": {
"cache_creation_input_token_cost": 6.25e-06,
@@ -9135,7 +9161,8 @@
"us": 1.1,
"fast": 6.0
},
"supports_max_reasoning_effort": true
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"claude-opus-4-7": {
"cache_creation_input_token_cost": 6.25e-06,
@@ -9167,7 +9194,9 @@
"provider_specific_entry": {
"us": 1.1,
"fast": 6.0
}
},
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"claude-opus-4-7-20260416": {
"cache_creation_input_token_cost": 6.25e-06,
@@ -9199,7 +9228,9 @@
"provider_specific_entry": {
"us": 1.1,
"fast": 6.0
}
},
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"claude-sonnet-4-20250514": {
"deprecation_date": "2026-05-14",
@@ -10352,6 +10383,22 @@
"supports_reasoning": true,
"supports_tool_choice": true
},
"dashscope/qwen-image-2.0": {
"litellm_provider": "dashscope",
"mode": "image_generation",
"source": "https://www.alibabacloud.com/help/en/model-studio/models",
"supported_endpoints": [
"/v1/images/generations"
]
},
"dashscope/qwen-image-2.0-pro": {
"litellm_provider": "dashscope",
"mode": "image_generation",
"source": "https://www.alibabacloud.com/help/en/model-studio/models",
"supported_endpoints": [
"/v1/images/generations"
]
},
"databricks/databricks-bge-large-en": {
"input_cost_per_token": 1.0003e-07,
"input_dbu_cost_per_token": 1.429e-06,
@@ -19226,6 +19273,42 @@
"supports_xhigh_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"gpt-5.5": {
"cache_read_input_token_cost": 5e-07,
"input_cost_per_token": 5e-06,
"litellm_provider": "openai",
"max_input_tokens": 272000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 3e-05,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
"/v1/responses"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_service_tier": true,
"supports_vision": true,
"supports_none_reasoning_effort": true,
"supports_xhigh_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"gpt-5.4": {
"cache_read_input_token_cost": 2.5e-07,
"cache_read_input_token_cost_above_272k_tokens": 5e-07,
@@ -25068,7 +25151,8 @@
"supports_reasoning": true,
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 159
"tool_use_system_prompt_tokens": 159,
"supports_minimal_reasoning_effort": true
},
"openrouter/anthropic/claude-opus-4.5": {
"cache_creation_input_token_cost": 6.25e-06,
@@ -25106,7 +25190,8 @@
"supports_reasoning": true,
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346
"tool_use_system_prompt_tokens": 346,
"supports_minimal_reasoning_effort": true
},
"openrouter/anthropic/claude-sonnet-4.5": {
"input_cost_per_image": 0.0048,
@@ -30134,7 +30219,8 @@
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true
"supports_vision": true,
"supports_minimal_reasoning_effort": true
},
"vercel_ai_gateway/anthropic/claude-sonnet-4": {
"cache_creation_input_token_cost": 3.75e-06,
@@ -31361,7 +31447,8 @@
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
"supports_max_reasoning_effort": true
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"vertex_ai/claude-opus-4-6@default": {
"cache_creation_input_token_cost": 6.25e-06,
@@ -31388,7 +31475,8 @@
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
"supports_max_reasoning_effort": true
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"vertex_ai/claude-opus-4-7": {
"cache_creation_input_token_cost": 6.25e-06,
@@ -31415,7 +31503,9 @@
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"tool_use_system_prompt_tokens": 346
"tool_use_system_prompt_tokens": 346,
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"vertex_ai/claude-opus-4-7@default": {
"cache_creation_input_token_cost": 6.25e-06,
@@ -31442,7 +31532,9 @@
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"tool_use_system_prompt_tokens": 346
"tool_use_system_prompt_tokens": 346,
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"vertex_ai/claude-sonnet-4-5": {
"cache_creation_input_token_cost": 3.75e-06,
@@ -31494,7 +31586,8 @@
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
}
},
"supports_minimal_reasoning_effort": true
},
"vertex_ai/claude-sonnet-4-5@20250929": {
"cache_creation_input_token_cost": 3.75e-06,
@@ -38361,7 +38454,8 @@
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
}
},
"supports_minimal_reasoning_effort": true
},
"duckduckgo/search": {
"litellm_provider": "duckduckgo",
@@ -7,6 +7,7 @@ Filters MCP tools semantically for /chat/completions and /responses endpoints.
from typing import TYPE_CHECKING, Any, Dict, List, Optional
from litellm._logging import verbose_logger
from litellm.proxy._experimental.mcp_server.utils import MCP_TOOL_PREFIX_SEPARATOR
if TYPE_CHECKING:
from semantic_router.routers import SemanticRouter
@@ -214,20 +215,89 @@ class SemanticMCPToolFilter:
return []
@staticmethod
def _name_matches_canonical(client_name: str, canonical: str) -> bool:
"""
Return True if a client-side tool name refers to the given canonical
MCP tool name.
MCP clients (e.g. opencode) commonly wrap the proxy's canonical tool
name with an additive namespace prefix of their own
(``<client_alias><sep><canonical>``). The prefix can use either a
dash or an underscore as separator regardless of what
``MCP_TOOL_PREFIX_SEPARATOR`` is set to on the proxy, because the
client doesn't know the proxy's separator.
The match is anchored: ``canonical`` must form the complete suffix
of ``client_name`` and be preceded by a separator character, so
``rain_gear`` does not match canonical ``ear``.
Suffix matching is additionally gated on ``canonical`` itself
containing ``MCP_TOOL_PREFIX_SEPARATOR``. Server-registered MCP
tools are always emitted as
``<server_name><MCP_TOOL_PREFIX_SEPARATOR><tool_name>`` (see
``add_server_prefix_to_name``), so a canonical without the
separator is not a namespaced MCP tool and falling back to
suffix matching would spuriously collide with unrelated local
user functions whose names end in the same characters.
"""
if client_name == canonical:
return True
if MCP_TOOL_PREFIX_SEPARATOR not in canonical:
return False
if len(client_name) <= len(canonical):
return False
if not client_name.endswith(canonical):
return False
separator = client_name[-len(canonical) - 1]
return separator in ("_", "-")
def _get_tools_by_names(
self, tool_names: List[str], available_tools: List[Any]
) -> List[Any]:
"""Get tools from available_tools by their names, preserving order."""
# Match tools from available_tools (preserves format - dict or MCPTool)
matched_tools = []
for tool in available_tools:
tool_name, _ = self._extract_tool_info(tool)
if tool_name in tool_names:
matched_tools.append(tool)
"""
Get tools from available_tools by their names, preserving the
semantic router's ordering.
# Reorder to match semantic router's ordering
tool_map = {self._extract_tool_info(t)[0]: t for t in matched_tools}
return [tool_map[name] for name in tool_names if name in tool_map]
Matching is tolerant of client-side namespace prefixes: if an
incoming tool arrived as ``<client_alias>_<canonical>`` while the
router returned ``<canonical>`` (see
``_name_matches_canonical``), that tool is still selected. The
returned tool object is the original from ``available_tools``, so
the client-facing name is preserved for tool-call round-trips.
"""
# Build an index of incoming tools by their client-facing name.
# Exact matches win over suffix matches when both are present, and
# each incoming tool is returned at most once even if two canonical
# names happen to be tail-compatible with the same incoming name.
available_by_name: Dict[str, Any] = {}
for tool in available_tools:
client_name, _ = self._extract_tool_info(tool)
if client_name and client_name not in available_by_name:
available_by_name[client_name] = tool
matched: List[Any] = []
used_ids: set = set()
for canonical in tool_names:
tool = available_by_name.get(canonical)
if tool is None:
# Prefer the shortest qualifying name. When several
# incoming tools suffix-match the same canonical (e.g.
# "my_search" and "my_tag_search" both end in "search"),
# the one closest in length to the canonical is the
# least-wrapped and most likely the intended target.
best_name: Optional[str] = None
for client_name in available_by_name:
if not self._name_matches_canonical(client_name, canonical):
continue
if best_name is None or len(client_name) < len(best_name):
best_name = client_name
if best_name is not None:
tool = available_by_name[best_name]
if tool is not None and id(tool) not in used_ids:
matched.append(tool)
used_ids.add(id(tool))
return matched
def extract_user_query(self, messages: List[Dict[str, Any]]) -> str:
"""
+77 -2
View File
@@ -905,6 +905,63 @@ async def get_default_end_user_budget(
return None
@log_db_metrics
async def get_team_member_default_budget(
budget_id: str,
prisma_client: Optional[PrismaClient],
user_api_key_cache: DualCache,
) -> Optional[LiteLLM_BudgetTable]:
"""
Fetches the team-level default per-member budget referenced by team.metadata["team_member_budget_id"].
This budget is applied to team members whose TeamMembership row has no
linked budget. Results are cached for performance.
Args:
budget_id: The budget_id pulled from team.metadata["team_member_budget_id"]
prisma_client: Database client instance
user_api_key_cache: Cache for storing/retrieving budget data
Returns:
LiteLLM_BudgetTable if found, None otherwise
"""
if prisma_client is None:
return None
cache_key = f"team_member_default_budget:{budget_id}"
cached_budget = await user_api_key_cache.async_get_cache(key=cache_key)
if isinstance(cached_budget, LiteLLM_BudgetTable):
return cached_budget
if isinstance(cached_budget, dict):
return LiteLLM_BudgetTable(**cached_budget)
try:
budget_record = await prisma_client.db.litellm_budgettable.find_unique(
where={"budget_id": budget_id}
)
if budget_record is None:
verbose_proxy_logger.warning(
f"Team-default member budget not found in database: {budget_id}"
)
return None
await user_api_key_cache.async_set_cache(
key=cache_key,
value=budget_record.dict(),
ttl=DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL,
)
return LiteLLM_BudgetTable(**budget_record.dict())
except Exception:
verbose_proxy_logger.exception(
f"Error fetching team-default member budget {budget_id}"
)
return None
async def _apply_default_budget_to_end_user(
end_user_obj: LiteLLM_EndUserTable,
prisma_client: PrismaClient,
@@ -3230,13 +3287,31 @@ async def _check_team_member_budget(
proxy_logging_obj=proxy_logging_obj,
)
# Per-member override wins; otherwise fall back to the team-level
# default configured via team.metadata["team_member_budget_id"].
team_member_budget: Optional[float] = None
if (
team_membership is not None
and team_membership.litellm_budget_table is not None
and team_membership.litellm_budget_table.max_budget is not None
):
team_member_budget = team_membership.litellm_budget_table.max_budget
team_member_spend = team_membership.spend or 0.0
else:
default_budget_id = (team_object.metadata or {}).get(
"team_member_budget_id"
)
if isinstance(default_budget_id, str):
default_budget = await get_team_member_default_budget(
budget_id=default_budget_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
)
if default_budget is not None:
team_member_budget = default_budget.max_budget
if team_member_budget is not None:
team_member_spend = (
team_membership.spend if team_membership is not None else 0.0
) or 0.0
# Read from cross-pod counter (Redis-first) if available
from litellm.proxy.proxy_server import get_current_spend
+9 -1
View File
@@ -151,7 +151,15 @@ def is_request_body_safe(
A malicious user can set the api_base to their own domain and invoke POST /chat/completions to intercept and steal the OpenAI API key.
Relevant issue: https://huntr.com/bounties/4001e1a2-7b7a-4776-a3ae-e6692ec3d997
"""
banned_params = ["api_base", "base_url", "user_config"]
banned_params = [
"api_base",
"base_url",
"user_config",
"aws_sts_endpoint",
"aws_web_identity_token",
"aws_role_name",
"vertex_credentials",
]
for param in banned_params:
if (
+20 -14
View File
@@ -632,20 +632,27 @@ class ResetBudgetJob:
now = datetime.utcnow()
# Note on raw SQL: prisma-client-python does not support null-filtering
# on `Json?` columns (no DbNull/JsonNull sentinel — see
# RobertCraigie/prisma-client-py#714). We use `query_raw` with
# `IS NOT NULL` so we don't materialize every key/team row on each
# tick of the reset job. Writes still go through the ORM.
# --- Keys ---
try:
all_keys = await self.prisma_client.db.litellm_verificationtoken.find_many(
where={"budget_limits": {"not": None}} # type: ignore[arg-type]
key_rows = await self.prisma_client.db.query_raw(
'SELECT token, budget_limits FROM "LiteLLM_VerificationToken" '
"WHERE budget_limits IS NOT NULL"
)
for key in all_keys:
raw = key.budget_limits # type: ignore[attr-defined]
for row in key_rows:
raw = row["budget_limits"]
if not raw:
continue
windows: list = raw if isinstance(raw, list) else json.loads(raw)
changed = False
for window in windows:
counter_key = (
f"spend:key:{key.token}:window:{window['budget_duration']}"
f"spend:key:{row['token']}:window:{window['budget_duration']}"
)
if await ResetBudgetJob._reset_expired_window(
window, counter_key, spend_counter_cache, now
@@ -653,7 +660,7 @@ class ResetBudgetJob:
changed = True
if changed:
await self.prisma_client.db.litellm_verificationtoken.update(
where={"token": key.token},
where={"token": row["token"]},
data={"budget_limits": json.dumps(windows)}, # type: ignore[arg-type]
)
except Exception as e:
@@ -663,26 +670,25 @@ class ResetBudgetJob:
# --- Teams ---
try:
all_teams = await self.prisma_client.db.litellm_teamtable.find_many(
where={"budget_limits": {"not": None}} # type: ignore[arg-type]
team_rows = await self.prisma_client.db.query_raw(
'SELECT team_id, budget_limits FROM "LiteLLM_TeamTable" '
"WHERE budget_limits IS NOT NULL"
)
for team in all_teams:
raw = team.budget_limits # type: ignore[attr-defined]
for row in team_rows:
raw = row["budget_limits"]
if not raw:
continue
windows = raw if isinstance(raw, list) else json.loads(raw)
changed = False
for window in windows:
counter_key = (
f"spend:team:{team.team_id}:window:{window['budget_duration']}"
)
counter_key = f"spend:team:{row['team_id']}:window:{window['budget_duration']}"
if await ResetBudgetJob._reset_expired_window(
window, counter_key, spend_counter_cache, now
):
changed = True
if changed:
await self.prisma_client.db.litellm_teamtable.update(
where={"team_id": team.team_id},
where={"team_id": row["team_id"]},
data={"budget_limits": json.dumps(windows)}, # type: ignore[arg-type]
)
except Exception as e:
@@ -285,6 +285,13 @@ async def image_edit_api(
if mask_files:
data["mask"] = mask_files
for _field in ("image", "mask"):
if _field in data and isinstance(data[_field], str):
raise HTTPException(
status_code=422,
detail=f"'{_field}' must be provided as a multipart file upload, not a string.",
)
# Ensure prompt exists in data (default to None for models that don't require it)
if "prompt" not in data:
data["prompt"] = None
@@ -52,12 +52,17 @@ from litellm.proxy._experimental.mcp_server.utils import (
from litellm.proxy._experimental.mcp_server.utils import (
validate_and_normalize_mcp_server_payload as _base_validate_and_normalize_mcp_server_payload,
)
from litellm.proxy.common_utils.encrypt_decrypt_utils import (
decrypt_value_helper,
encrypt_value_helper,
)
router = APIRouter(prefix="/v1/mcp", tags=["mcp"])
MCP_AVAILABLE: bool = True
TEMPORARY_MCP_SERVER_TTL_SECONDS = 300
TEMPORARY_MCP_SERVER_REDIS_KEY_PREFIX = "litellm:mcp:temporary_server"
def does_mcp_server_exist(
@@ -329,13 +334,115 @@ if MCP_AVAILABLE:
)
return server
def get_cached_temporary_mcp_server(
async def _cache_temporary_mcp_server_in_redis(
server: MCPServer, ttl_seconds: int
) -> None:
"""
Best-effort write-through to Redis so temporary MCP OAuth sessions are
shared across proxy instances. Keep local in-memory cache as fallback.
"""
if litellm.cache is None or not hasattr(litellm.cache, "cache"):
return
cache_backend = getattr(litellm.cache, "cache", None)
if cache_backend is None or not hasattr(cache_backend, "async_set_cache"):
return
payload: Dict[str, Any] = server.model_dump(mode="json")
payload_json = json.dumps(payload)
try:
encrypted_payload = encrypt_value_helper(payload_json)
except Exception as e:
verbose_proxy_logger.debug(
f"Failed to encrypt temporary MCP server payload for Redis cache: {str(e)}"
)
return
if not isinstance(encrypted_payload, str):
verbose_proxy_logger.debug(
"Encrypted temporary MCP payload is not a string; skipping Redis cache write"
)
return
try:
await cache_backend.async_set_cache(
key=f"{TEMPORARY_MCP_SERVER_REDIS_KEY_PREFIX}:{server.server_id}",
value=encrypted_payload,
ttl=max(1, ttl_seconds),
)
except Exception as e:
verbose_proxy_logger.debug(
f"Failed to write temporary MCP server to Redis cache: {str(e)}"
)
async def _get_temporary_mcp_server_from_redis(
server_id: str,
) -> Optional[MCPServer]:
"""
Best-effort read from Redis shared cache. Returns None on miss/errors.
Values must be encrypted strings (same contract as _cache_temporary_mcp_server_in_redis);
legacy plaintext dict payloads are rejected.
"""
if litellm.cache is None or not hasattr(litellm.cache, "cache"):
return None
cache_backend = getattr(litellm.cache, "cache", None)
if cache_backend is None or not hasattr(cache_backend, "async_get_cache"):
return None
try:
cached_server = await cache_backend.async_get_cache(
key=f"{TEMPORARY_MCP_SERVER_REDIS_KEY_PREFIX}:{server_id}"
)
except Exception as e:
verbose_proxy_logger.debug(
f"Failed reading temporary MCP server from Redis cache: {str(e)}"
)
return None
if not isinstance(cached_server, str):
verbose_proxy_logger.debug(
"Temporary MCP Redis cache value must be an encrypted string; rejecting non-string payload"
)
return None
decrypted_json = decrypt_value_helper(
value=cached_server,
key="temporary_mcp_server",
exception_type="debug",
)
if decrypted_json is None:
return None
try:
loaded = json.loads(decrypted_json)
except Exception as e:
verbose_proxy_logger.debug(
f"Invalid decrypted temporary MCP payload in Redis cache: {str(e)}"
)
return None
if not isinstance(loaded, dict):
return None
payload_dict: Dict[str, Any] = loaded
try:
return MCPServer(**payload_dict)
except Exception as e:
verbose_proxy_logger.debug(
f"Invalid temporary MCP server payload in Redis cache: {str(e)}"
)
return None
async def get_cached_temporary_mcp_server(
server_id: str,
) -> Optional[MCPServer]:
_prune_expired_temporary_mcp_servers()
entry = _temporary_mcp_servers.get(server_id)
if entry is None:
return None
redis_server = await _get_temporary_mcp_server_from_redis(server_id)
if redis_server is None:
return None
# Intentionally avoid repopulating local cache from Redis to prevent
# extending effective lifetime beyond the remaining Redis TTL.
return redis_server
return entry.server
def _redact_mcp_credentials(
@@ -1325,6 +1432,10 @@ if MCP_AVAILABLE:
temporary_server,
ttl_seconds=TEMPORARY_MCP_SERVER_TTL_SECONDS,
)
await _cache_temporary_mcp_server_in_redis(
temporary_server,
ttl_seconds=TEMPORARY_MCP_SERVER_TTL_SECONDS,
)
except Exception as e:
verbose_proxy_logger.exception(
f"Error caching temporary mcp server: {str(e)}"
@@ -1336,10 +1447,10 @@ if MCP_AVAILABLE:
return _redact_mcp_credentials(temp_record)
def _get_cached_temporary_mcp_server_or_404(
async def _get_cached_temporary_mcp_server_or_404(
server_id: str, request: Optional[Request] = None
) -> MCPServer:
server = get_cached_temporary_mcp_server(server_id)
server = await get_cached_temporary_mcp_server(server_id)
if server is None:
# Fall back to real DB/config server (e.g. for the user-side OAuth flow
# which calls these endpoints with a real server_id, not a temp session id).
@@ -1378,7 +1489,9 @@ if MCP_AVAILABLE:
response_type: Optional[str] = None,
scope: Optional[str] = None,
):
mcp_server = _get_cached_temporary_mcp_server_or_404(server_id, request=request)
mcp_server = await _get_cached_temporary_mcp_server_or_404(
server_id, request=request
)
# Use the server's stored client_id when the caller doesn't supply one
resolved_client_id = mcp_server.client_id or client_id or ""
if not resolved_client_id:
@@ -1422,7 +1535,9 @@ if MCP_AVAILABLE:
refresh_token: Optional[str] = Form(None),
scope: Optional[str] = Form(None),
):
mcp_server = _get_cached_temporary_mcp_server_or_404(server_id, request=request)
mcp_server = await _get_cached_temporary_mcp_server_or_404(
server_id, request=request
)
resolved_client_id = mcp_server.client_id or client_id or ""
if not resolved_client_id:
raise HTTPException(
@@ -1458,7 +1573,9 @@ if MCP_AVAILABLE:
server_id: str,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
mcp_server = _get_cached_temporary_mcp_server_or_404(server_id, request=request)
mcp_server = await _get_cached_temporary_mcp_server_or_404(
server_id, request=request
)
request_data = await _read_request_body(request=request)
data: dict = {**request_data}
@@ -302,14 +302,15 @@ class TeamMemberBudgetHandler:
prisma_client: PrismaClient,
) -> None:
"""
Create team_memberships entries for existing members that don't have one.
Ensure every team member has a TeamMembership row linked to the
team_member_budget.
Called after team_member_budget is set/updated on a team to ensure
members who joined before the budget was configured also get budget
enforcement.
Only creates missing entries does not touch existing memberships
(which may carry individual per-member budgets).
Called after team_member_budget is set/updated on a team. Creates
rows for members who don't have one, and populates budget_id on
existing rows where it is NULL. Rows with a non-NULL budget_id
are left untouched, which preserves per-member overrides but also
means rows pointing to a prior team-default budget_id are not
migrated to the new one.
"""
if not members_with_roles:
return
@@ -347,6 +348,21 @@ class TeamMemberBudgetHandler:
_sanitize_for_log(team_member_budget_id),
)
# Heal existing membership rows that predate the team_member_budget
# configuration: populate budget_id where it is currently NULL.
# Rows with an explicit budget_id (per-member override) are left alone.
updated = await prisma_client.db.litellm_teammembership.update_many(
where={"team_id": team_id, "budget_id": None},
data={"budget_id": team_member_budget_id},
)
if updated:
verbose_proxy_logger.info(
"Populated budget_id on %d existing team_memberships for team %s with budget %s",
updated,
_sanitize_for_log(team_id),
_sanitize_for_log(team_member_budget_id),
)
def _get_default_team_param(field: str) -> Any:
"""
@@ -8,6 +8,7 @@ Use litellm with Anthropic SDK, Vertex AI SDK, Cohere SDK, etc.
import json
import os
import re
from typing import Any, Optional, Tuple, Union, cast
import httpx
@@ -1496,10 +1497,18 @@ class VertexAIPassThroughHandler(BaseVertexAIPassThroughHandler):
def get_vertex_base_url(vertex_location: Optional[str]) -> str:
"""
Returns the base URL for Vertex AI based on the provided location.
Base URL for Vertex AI pass-through (trailing slash for URL joining).
Keep location rules aligned with ``litellm.llms.vertex_ai.common_utils.get_vertex_base_url``.
"""
if vertex_location == "global":
return "https://aiplatform.googleapis.com/"
if vertex_location is None:
raise ValueError("vertex_location is required")
if not re.match(r"^[a-z][a-z0-9-]*$", vertex_location):
raise ValueError("Invalid vertex_location format")
if "-" not in vertex_location:
return f"https://aiplatform.{vertex_location}.rep.googleapis.com/"
return f"https://{vertex_location}-aiplatform.googleapis.com/"
@@ -1703,7 +1712,8 @@ async def _base_vertex_proxy_route(
Base function for Vertex AI passthrough routes.
Handles common logic for all Vertex AI services.
Default base_target_url is `https://{vertex_location}-aiplatform.googleapis.com/`
Default base_target_url is derived from ``get_vertex_base_url`` in this module
(regional, ``global``, or multi-region ``.rep.`` hosts), with a trailing slash.
Args:
endpoint: The endpoint path
@@ -2275,11 +2285,7 @@ async def vertex_ai_live_websocket_passthrough(
return
host_location = resolved_location or vertex_llm_base.get_default_vertex_location()
host = (
"aiplatform.googleapis.com"
if host_location == "global"
else f"{host_location}-aiplatform.googleapis.com"
)
host = get_vertex_base_url(host_location).removeprefix("https://").rstrip("/")
service_url = (
f"wss://{host}/ws/google.cloud.aiplatform.v1.LlmBidiService/BidiGenerateContent"
)
+83 -14
View File
@@ -1908,34 +1908,102 @@ async def increment_spend_counters(
)
async def _reseed_spend_from_db(counter_key: str) -> float:
"""
Read the authoritative spend for a missing counter from the DB. The
counter_key prefix encodes the table to query:
spend:key:{token} -> LiteLLM_VerificationToken.spend
spend:team:{team_id} -> LiteLLM_TeamTable.spend
spend:team_member:{uid}:{tid} -> LiteLLM_TeamMembership.spend
spend:user:{user_id} -> LiteLLM_UserTable.spend
spend:org:{org_id} -> LiteLLM_OrganizationTable.spend
Returns 0.0 if prisma is unavailable, the row is missing, or the
key format is unrecognized. On failure, logs and returns 0.0 rather
than raising so the caller can still record the current increment.
"""
if prisma_client is None:
return 0.0
# Per-window counters (spend:*:window:{duration}) share prefixes with
# primary counters but don't correspond to a DB row; their ambiguity
# would otherwise be silently parsed as a regular counter and miss.
if ":window:" in counter_key:
return 0.0
try:
if counter_key.startswith("spend:key:"):
token = counter_key[len("spend:key:") :]
row = await prisma_client.db.litellm_verificationtoken.find_unique(
where={"token": token}
)
elif counter_key.startswith("spend:team_member:"):
suffix = counter_key[len("spend:team_member:") :]
if ":" not in suffix:
return 0.0
user_id, team_id = suffix.rsplit(":", 1)
row = await prisma_client.db.litellm_teammembership.find_unique(
where={"user_id_team_id": {"user_id": user_id, "team_id": team_id}}
)
elif counter_key.startswith("spend:team:"):
team_id = counter_key[len("spend:team:") :]
row = await prisma_client.db.litellm_teamtable.find_unique(
where={"team_id": team_id}
)
elif counter_key.startswith("spend:user:"):
user_id = counter_key[len("spend:user:") :]
row = await prisma_client.db.litellm_usertable.find_unique(
where={"user_id": user_id}
)
elif counter_key.startswith("spend:org:"):
org_id = counter_key[len("spend:org:") :]
row = await prisma_client.db.litellm_organizationtable.find_unique(
where={"organization_id": org_id}
)
else:
return 0.0
except Exception:
verbose_proxy_logger.exception(
"Failed to reseed spend counter %s from DB", counter_key
)
return 0.0
if row is None:
return 0.0
return float(getattr(row, "spend", 0.0) or 0.0)
async def _init_and_increment_spend_counter(
counter_key: str,
source_cache_key: str,
increment: float,
):
"""
Initialize counter from cached object's DB-loaded spend if not yet set,
then atomically increment in both in-memory and Redis.
Initialize counter from the authoritative DB spend value if not yet
set, then atomically increment in both in-memory and Redis.
On first access per pod:
1. Check spend_counter_cache (in-memory -> Redis via DualCache for init check)
2. If not found anywhere, read base spend from user_api_key_cache (DB-loaded object)
1. Check spend_counter_cache (in-memory -> Redis via DualCache)
2. If not found, reseed from the DB (`_reseed_spend_from_db`). Falls
back to the cached object's `.spend` via user_api_key_cache only
if prisma is unavailable, since that value can lag the flusher.
3. Seed counter via async_increment_cache (not async_set_cache) to avoid a
check-then-set race: if two pods cold-start simultaneously, both may see
the counter as absent and seed it. Using increment instead of set means
the worst case is over-counting (conservative blocks slightly early)
rather than under-counting (would allow overspend).
the counter as absent and seed it. Using increment means the worst case
is over-counting (conservative, blocks slightly early) rather than
under-counting (would allow overspend).
4. Increment atomically (both in-memory + Redis)
"""
current = await spend_counter_cache.async_get_cache(key=counter_key)
if current is None:
source = await user_api_key_cache.async_get_cache(key=source_cache_key)
base_spend = 0.0
if source is not None:
if isinstance(source, dict):
base_spend = source.get("spend", 0.0) or 0.0
else:
base_spend = getattr(source, "spend", 0.0) or 0.0
base_spend = await _reseed_spend_from_db(counter_key)
if prisma_client is None:
# Best-effort fallback when prisma is unavailable (tests or
# early-startup paths). May be stale but avoids resetting to 0.
source = await user_api_key_cache.async_get_cache(key=source_cache_key)
if source is not None:
if isinstance(source, dict):
base_spend = source.get("spend", 0.0) or 0.0
else:
base_spend = getattr(source, "spend", 0.0) or 0.0
if base_spend > 0:
await spend_counter_cache.async_increment_cache(
key=counter_key, value=base_spend
@@ -7289,6 +7357,7 @@ async def chat_completion( # noqa: PLR0915
and user_api_key_dict.agent_id is not None
):
data["metadata"]["agent_id"] = user_api_key_dict.agent_id
base_llm_response_processor = ProxyBaseLLMRequestProcessing(data=data)
try:
result = await base_llm_response_processor.base_process_llm_request(
+2
View File
@@ -139,7 +139,9 @@ class ProviderSpecificModelInfo(TypedDict, total=False):
supports_reasoning: Optional[bool]
supports_url_context: Optional[bool]
supports_none_reasoning_effort: Optional[bool]
supports_minimal_reasoning_effort: Optional[bool]
supports_xhigh_reasoning_effort: Optional[bool]
supports_max_reasoning_effort: Optional[bool]
class SearchContextCostPerQuery(TypedDict, total=False):
+12
View File
@@ -5893,9 +5893,15 @@ def _get_model_info_helper( # noqa: PLR0915
supports_none_reasoning_effort=_model_info.get(
"supports_none_reasoning_effort", None
),
supports_minimal_reasoning_effort=_model_info.get(
"supports_minimal_reasoning_effort", None
),
supports_xhigh_reasoning_effort=_model_info.get(
"supports_xhigh_reasoning_effort", None
),
supports_max_reasoning_effort=_model_info.get(
"supports_max_reasoning_effort", None
),
supports_computer_use=_model_info.get("supports_computer_use", None),
search_context_cost_per_query=_model_info.get(
"search_context_cost_per_query", None
@@ -8946,6 +8952,12 @@ class ProviderConfigManager:
)
return get_openrouter_image_generation_config(model)
elif LlmProviders.DASHSCOPE == provider:
from litellm.llms.dashscope.image_generation import (
get_dashscope_image_generation_config,
)
return get_dashscope_image_generation_config(model)
return None
@staticmethod
+126 -32
View File
@@ -1006,7 +1006,8 @@
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true,
"supports_max_reasoning_effort": true
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"global.anthropic.claude-opus-4-6-v1": {
"cache_creation_input_token_cost": 6.25e-06,
@@ -1034,7 +1035,8 @@
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true,
"supports_max_reasoning_effort": true
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"us.anthropic.claude-opus-4-6-v1": {
"cache_creation_input_token_cost": 6.875e-06,
@@ -1062,7 +1064,8 @@
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true,
"supports_max_reasoning_effort": true
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"eu.anthropic.claude-opus-4-6-v1": {
"cache_creation_input_token_cost": 6.875e-06,
@@ -1090,7 +1093,8 @@
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true,
"supports_max_reasoning_effort": true
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"au.anthropic.claude-opus-4-6-v1": {
"cache_creation_input_token_cost": 6.875e-06,
@@ -1118,7 +1122,8 @@
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true,
"supports_max_reasoning_effort": true
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"anthropic.claude-opus-4-7": {
"cache_creation_input_token_cost": 6.25e-06,
@@ -1146,7 +1151,9 @@
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true
"supports_native_structured_output": true,
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"anthropic.claude-mythos-preview": {
"input_cost_per_token": 0,
@@ -1188,7 +1195,9 @@
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true
"supports_native_structured_output": true,
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"us.anthropic.claude-opus-4-7": {
"cache_creation_input_token_cost": 6.875e-06,
@@ -1216,7 +1225,9 @@
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true
"supports_native_structured_output": true,
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"eu.anthropic.claude-opus-4-7": {
"cache_creation_input_token_cost": 6.875e-06,
@@ -1244,7 +1255,9 @@
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true
"supports_native_structured_output": true,
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"au.anthropic.claude-opus-4-7": {
"cache_creation_input_token_cost": 6.875e-06,
@@ -1272,7 +1285,9 @@
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true
"supports_native_structured_output": true,
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"anthropic.claude-sonnet-4-6": {
"cache_creation_input_token_cost": 3.75e-06,
@@ -1299,7 +1314,8 @@
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true
"supports_native_structured_output": true,
"supports_minimal_reasoning_effort": true
},
"global.anthropic.claude-sonnet-4-6": {
"cache_creation_input_token_cost": 3.75e-06,
@@ -1326,7 +1342,8 @@
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true
"supports_native_structured_output": true,
"supports_minimal_reasoning_effort": true
},
"us.anthropic.claude-sonnet-4-6": {
"cache_creation_input_token_cost": 4.125e-06,
@@ -1353,7 +1370,8 @@
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true
"supports_native_structured_output": true,
"supports_minimal_reasoning_effort": true
},
"eu.anthropic.claude-sonnet-4-6": {
"cache_creation_input_token_cost": 4.125e-06,
@@ -1380,7 +1398,8 @@
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true
"supports_native_structured_output": true,
"supports_minimal_reasoning_effort": true
},
"au.anthropic.claude-sonnet-4-6": {
"cache_creation_input_token_cost": 4.125e-06,
@@ -1407,7 +1426,8 @@
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true
"supports_native_structured_output": true,
"supports_minimal_reasoning_effort": true
},
"anthropic.claude-sonnet-4-20250514-v1:0": {
"cache_creation_input_token_cost": 3.75e-06,
@@ -1925,7 +1945,8 @@
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 159,
"supports_max_reasoning_effort": true
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"azure_ai/claude-opus-4-7": {
"input_cost_per_token": 5e-06,
@@ -1953,7 +1974,9 @@
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"tool_use_system_prompt_tokens": 159
"tool_use_system_prompt_tokens": 159,
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"azure_ai/claude-opus-4-1": {
"cache_creation_input_token_cost": 1.875e-05,
@@ -2017,7 +2040,8 @@
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346
"tool_use_system_prompt_tokens": 346,
"supports_minimal_reasoning_effort": true
},
"azure/computer-use-preview": {
"input_cost_per_token": 3e-06,
@@ -8923,7 +8947,8 @@
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346
"tool_use_system_prompt_tokens": 346,
"supports_minimal_reasoning_effort": true
},
"claude-sonnet-4-5-20250929-v1:0": {
"cache_creation_input_token_cost": 3.75e-06,
@@ -9117,7 +9142,8 @@
"us": 1.1,
"fast": 6.0
},
"supports_max_reasoning_effort": true
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"claude-opus-4-6-20260205": {
"cache_creation_input_token_cost": 6.25e-06,
@@ -9149,7 +9175,8 @@
"us": 1.1,
"fast": 6.0
},
"supports_max_reasoning_effort": true
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"claude-opus-4-7": {
"cache_creation_input_token_cost": 6.25e-06,
@@ -9181,7 +9208,9 @@
"provider_specific_entry": {
"us": 1.1,
"fast": 6.0
}
},
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"claude-opus-4-7-20260416": {
"cache_creation_input_token_cost": 6.25e-06,
@@ -9213,7 +9242,9 @@
"provider_specific_entry": {
"us": 1.1,
"fast": 6.0
}
},
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"claude-sonnet-4-20250514": {
"deprecation_date": "2026-05-14",
@@ -10366,6 +10397,22 @@
"supports_reasoning": true,
"supports_tool_choice": true
},
"dashscope/qwen-image-2.0": {
"litellm_provider": "dashscope",
"mode": "image_generation",
"source": "https://www.alibabacloud.com/help/en/model-studio/models",
"supported_endpoints": [
"/v1/images/generations"
]
},
"dashscope/qwen-image-2.0-pro": {
"litellm_provider": "dashscope",
"mode": "image_generation",
"source": "https://www.alibabacloud.com/help/en/model-studio/models",
"supported_endpoints": [
"/v1/images/generations"
]
},
"databricks/databricks-bge-large-en": {
"input_cost_per_token": 1.0003e-07,
"input_dbu_cost_per_token": 1.429e-06,
@@ -19240,6 +19287,42 @@
"supports_xhigh_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"gpt-5.5": {
"cache_read_input_token_cost": 5e-07,
"input_cost_per_token": 5e-06,
"litellm_provider": "openai",
"max_input_tokens": 272000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 3e-05,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
"/v1/responses"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_service_tier": true,
"supports_vision": true,
"supports_none_reasoning_effort": true,
"supports_xhigh_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"gpt-5.4": {
"cache_read_input_token_cost": 2.5e-07,
"cache_read_input_token_cost_above_272k_tokens": 5e-07,
@@ -25082,7 +25165,8 @@
"supports_reasoning": true,
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 159
"tool_use_system_prompt_tokens": 159,
"supports_minimal_reasoning_effort": true
},
"openrouter/anthropic/claude-opus-4.5": {
"cache_creation_input_token_cost": 6.25e-06,
@@ -25120,7 +25204,8 @@
"supports_reasoning": true,
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346
"tool_use_system_prompt_tokens": 346,
"supports_minimal_reasoning_effort": true
},
"openrouter/anthropic/claude-sonnet-4.5": {
"input_cost_per_image": 0.0048,
@@ -30170,7 +30255,8 @@
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true
"supports_vision": true,
"supports_minimal_reasoning_effort": true
},
"vercel_ai_gateway/anthropic/claude-sonnet-4": {
"cache_creation_input_token_cost": 3.75e-06,
@@ -31397,7 +31483,8 @@
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
"supports_max_reasoning_effort": true
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"vertex_ai/claude-opus-4-6@default": {
"cache_creation_input_token_cost": 6.25e-06,
@@ -31424,7 +31511,8 @@
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
"supports_max_reasoning_effort": true
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"vertex_ai/claude-opus-4-7": {
"cache_creation_input_token_cost": 6.25e-06,
@@ -31451,7 +31539,9 @@
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"tool_use_system_prompt_tokens": 346
"tool_use_system_prompt_tokens": 346,
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"vertex_ai/claude-opus-4-7@default": {
"cache_creation_input_token_cost": 6.25e-06,
@@ -31478,7 +31568,9 @@
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"tool_use_system_prompt_tokens": 346
"tool_use_system_prompt_tokens": 346,
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"vertex_ai/claude-sonnet-4-5": {
"cache_creation_input_token_cost": 3.75e-06,
@@ -31530,7 +31622,8 @@
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
}
},
"supports_minimal_reasoning_effort": true
},
"vertex_ai/claude-sonnet-4-5@20250929": {
"cache_creation_input_token_cost": 3.75e-06,
@@ -38424,7 +38517,8 @@
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
}
},
"supports_minimal_reasoning_effort": true
},
"duckduckgo/search": {
"litellm_provider": "duckduckgo",
+3 -3
View File
@@ -1,6 +1,6 @@
[project]
name = "litellm"
version = "1.83.11"
version = "1.83.12"
description = "Library to easily interface with LLM API providers"
readme = "README.md"
requires-python = ">=3.10, <3.14"
@@ -52,7 +52,7 @@ proxy = [
"azure-identity==1.25.2",
"azure-storage-blob==12.28.0",
"mcp==1.26.0",
"litellm-proxy-extras==0.4.67",
"litellm-proxy-extras==0.4.68",
"litellm-enterprise==0.1.38",
"RestrictedPython==8.1",
"rich==13.9.4",
@@ -236,7 +236,7 @@ source-exclude = [
profile = "black"
[tool.commitizen]
version = "1.83.11"
version = "1.83.12"
version_files = [
"pyproject.toml:^version",
]
@@ -547,6 +547,8 @@ async def test_router_caching_ttl():
assert router.cache.redis_cache is not None
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
increment_cache_kwargs = {}
with patch.object(
router.cache,
@@ -555,6 +557,10 @@ async def test_router_caching_ttl():
) as mock_client:
await router.acompletion(model=model, messages=messages)
# Async success callbacks are dispatched to GLOBAL_LOGGING_WORKER's
# background queue; drain it before asserting the mock was invoked.
await GLOBAL_LOGGING_WORKER.flush()
# mock_client.assert_called_once()
print(f"mock_client.call_args.kwargs: {mock_client.call_args.kwargs}")
print(f"mock_client.call_args.args: {mock_client.call_args.args}")
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
{
"name": "litellm-pass-through-tests",
"version": "0.0.0",
"private": true,
"description": "JS pass-through tests for Vertex AI / Google AI Studio routes. CI-only; not published.",
"dependencies": {
"@google-cloud/vertexai": "1.9.3",
"@google/generative-ai": "0.21.0"
},
"devDependencies": {
"jest": "29.7.0"
}
}
@@ -48,6 +48,22 @@ POLL_TIMEOUT_SECONDS = 60
TOLERANCE = 1e-10
def _make_test_session() -> aiohttp.ClientSession:
"""
Session tuned for CI reliability:
- force_close: avoid aiohttp reusing a TCP connection that the proxy/kernel
silently closed during the long idle window between setup POSTs and the
later poll loop (observed failure mode: ConnectionTimeoutError on the
first /key/info call after 20 chat completions).
- explicit connect timeout: surface a blocked proxy event loop quickly
instead of hanging on aiohttp's 5-minute default total timeout.
"""
return aiohttp.ClientSession(
connector=aiohttp.TCPConnector(force_close=True),
timeout=aiohttp.ClientTimeout(total=30, connect=10),
)
async def create_organization(session, organization_alias: str):
"""Helper function to create a new organization"""
url = "http://0.0.0.0:4000/organization/new"
@@ -156,7 +172,16 @@ async def poll_key_spend_until(session, key: str, expected: float) -> float:
start = time.time()
last_spend = 0.0
while time.time() - start < POLL_TIMEOUT_SECONDS:
key_info = await get_spend_info(session, "key", key)
try:
key_info = await get_spend_info(session, "key", key)
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
print(
f"Transient transport error during spend poll: "
f"{type(exc).__name__}: {exc}. Retrying... "
f"({time.time() - start:.1f}s elapsed)"
)
await asyncio.sleep(POLL_INTERVAL_SECONDS)
continue
last_spend = key_info["info"]["spend"]
if abs(last_spend - expected) < TOLERANCE:
print(
@@ -193,7 +218,7 @@ async def test_basic_spend_accuracy():
"""
NUM_LLM_REQUESTS = 20
async with aiohttp.ClientSession() as session:
async with _make_test_session() as session:
await assert_proxy_healthy(session)
org_response = await create_organization(
@@ -278,7 +303,7 @@ async def test_long_term_spend_accuracy_with_bursts():
BURST_1_REQUESTS = 22
BURST_2_REQUESTS = 12
async with aiohttp.ClientSession() as session:
async with _make_test_session() as session:
await assert_proxy_healthy(session)
org_response = await create_organization(
@@ -1,4 +1,4 @@
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional
from unittest.mock import MagicMock, patch
import pytest
@@ -26,7 +26,12 @@ class MockImageEditConfig(BaseImageEditConfig):
return "https://example.com/api"
def validate_environment(
self, headers: dict, model: str, api_key: str = None
self,
headers: dict,
model: str,
api_key: Optional[str] = None,
litellm_params: Optional[dict] = None,
api_base: Optional[str] = None,
) -> dict:
return headers
@@ -262,3 +267,141 @@ class TestImageEditCustomPricing:
def test_custom_pricing_not_detected_without_model_info(self):
litellm_params = {"litellm_call_id": "test-call-id"}
assert use_custom_pricing_for_model(litellm_params) is False
class TestImageEditHandlerCredentialsForwarding:
"""
Regression tests for Vertex AI image_edit credentials bug.
image_edit handler must forward litellm_params to validate_environment,
so that credentials passed via YAML config (vertex_ai_project,
vertex_ai_credentials, etc.) reach the auth layer instead of falling
through to Application Default Credentials.
"""
def test_vertex_gemini_image_edit_reads_credentials_from_litellm_params(self):
"""
VertexAIGeminiImageEditConfig.validate_environment should read
vertex_ai_project/vertex_ai_credentials from litellm_params first.
"""
from litellm.llms.vertex_ai.image_edit.vertex_gemini_transformation import (
VertexAIGeminiImageEditConfig,
)
config = VertexAIGeminiImageEditConfig()
litellm_params = {
"vertex_ai_project": "test-project-from-params",
"vertex_ai_credentials": "/path/to/creds.json",
}
with patch.object(
config, "_ensure_access_token", return_value=("token", "project")
) as mock_ensure:
config.validate_environment(
headers={},
model="test-model",
litellm_params=litellm_params,
)
mock_ensure.assert_called_once()
call_kwargs = mock_ensure.call_args[1]
assert call_kwargs["credentials"] == "/path/to/creds.json"
assert call_kwargs["project_id"] == "test-project-from-params"
def test_vertex_imagen_image_edit_reads_credentials_from_litellm_params(self):
"""
VertexAIImagenImageEditConfig.validate_environment should read
vertex_ai_project/vertex_ai_credentials from litellm_params first.
"""
from litellm.llms.vertex_ai.image_edit.vertex_imagen_transformation import (
VertexAIImagenImageEditConfig,
)
config = VertexAIImagenImageEditConfig()
litellm_params = {
"vertex_ai_project": "test-project-from-params",
"vertex_ai_credentials": "/path/to/creds.json",
}
with patch.object(
config, "_ensure_access_token", return_value=("token", "project")
) as mock_ensure:
config.validate_environment(
headers={},
model="test-model",
litellm_params=litellm_params,
)
mock_ensure.assert_called_once()
call_kwargs = mock_ensure.call_args[1]
assert call_kwargs["credentials"] == "/path/to/creds.json"
assert call_kwargs["project_id"] == "test-project-from-params"
def test_vertex_imagen_get_complete_url_reads_project_and_location_from_litellm_params(
self,
):
"""
VertexAIImagenImageEditConfig.get_complete_url should read
vertex_ai_project and vertex_ai_location from litellm_params,
not only from env vars / global settings.
"""
from litellm.llms.vertex_ai.image_edit.vertex_imagen_transformation import (
VertexAIImagenImageEditConfig,
)
config = VertexAIImagenImageEditConfig()
litellm_params = {
"vertex_ai_project": "param-project",
"vertex_ai_location": "us-east1",
}
url = config.get_complete_url(
model="vertex_ai/imagegeneration@002",
api_base=None,
litellm_params=litellm_params,
)
assert "param-project" in url
assert "us-east1" in url
def test_validate_environment_signature_includes_litellm_params(self):
"""
All image_edit config validate_environment methods should accept
litellm_params to allow credentials to be forwarded from the handler.
"""
import inspect
from litellm.llms.vertex_ai.image_edit.vertex_gemini_transformation import (
VertexAIGeminiImageEditConfig,
)
from litellm.llms.vertex_ai.image_edit.vertex_imagen_transformation import (
VertexAIImagenImageEditConfig,
)
from litellm.llms.openai.image_edit.transformation import (
OpenAIImageEditConfig,
)
configs = [
VertexAIGeminiImageEditConfig(),
VertexAIImagenImageEditConfig(),
OpenAIImageEditConfig(),
MockImageEditConfig(),
]
for config in configs:
sig = inspect.signature(config.validate_environment)
params = list(sig.parameters.keys())
assert "litellm_params" in params, (
f"{config.__class__.__name__}.validate_environment "
"missing litellm_params parameter"
)
assert "api_base" in params, (
f"{config.__class__.__name__}.validate_environment "
"missing api_base parameter"
)
@@ -328,6 +328,43 @@ def test_generic_cost_per_token_gpt54_above_272k_tokens():
assert round(completion_cost, 10) == round(expected_completion, 10)
def test_generic_cost_per_token_gpt55():
"""gpt-5.5: base pricing — $5/1M input, $30/1M output, $0.50/1M cached input."""
model = "gpt-5.5"
custom_llm_provider = "openai"
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
litellm.model_cost = litellm.get_model_cost_map(url="")
model_cost_map = litellm.model_cost[model]
# Sanity-check the map values match OpenAI's published pricing.
assert model_cost_map["input_cost_per_token"] == 5e-6
assert model_cost_map["output_cost_per_token"] == 3e-5
assert model_cost_map["cache_read_input_token_cost"] == 5e-7
assert model_cost_map["litellm_provider"] == "openai"
assert model_cost_map["mode"] == "chat"
assert model_cost_map["max_input_tokens"] == 272000
prompt_tokens = 1000
completion_tokens = 500
usage = Usage(
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=prompt_tokens + completion_tokens,
)
prompt_cost, completion_cost = generic_cost_per_token(
model=model,
usage=usage,
custom_llm_provider=custom_llm_provider,
)
assert round(prompt_cost, 10) == round(
model_cost_map["input_cost_per_token"] * prompt_tokens, 10
)
assert round(completion_cost, 10) == round(
model_cost_map["output_cost_per_token"] * completion_tokens, 10
)
def test_generic_cost_per_token_anthropic_prompt_caching():
model = "claude-sonnet-4@20250514"
usage = Usage(
@@ -11,6 +11,7 @@ sys.path.insert(
from litellm.litellm_core_utils.prompt_templates.common_utils import (
add_system_prompt_to_messages,
get_file_ids_from_messages,
get_format_from_file_id,
handle_any_messages_to_chat_completion_str_messages_conversion,
split_concatenated_json_objects,
@@ -254,3 +255,115 @@ def test_split_concatenated_json_invalid_raises():
"""Completely invalid JSON raises JSONDecodeError."""
with pytest.raises(json.JSONDecodeError):
split_concatenated_json_objects("not json at all")
# ---------------------------------------------------------------------------
# Regression tests for non-OpenAI file content blocks.
#
# `type: "file"` is a public content-block discriminator. Several producers
# (LangChain v1, provider-native shapes, custom user code) emit blocks with
# `type: "file"` but without the OpenAI Chat Completions `file` sub-dict.
# The discovery helpers below are used unconditionally inside
# `AnthropicConfig.validate_environment`, so any crash there surfaces as a
# `500 InternalServerError` before the request is even dispatched.
# ---------------------------------------------------------------------------
def test_get_file_ids_from_messages_skips_langchain_v1_file_block():
"""A LangChain v1 standardized file block must not crash file-id discovery."""
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "summarise this PDF"},
# LangChain v1 shape produced by `_normalize_messages`.
# No `file` sub-dict: the discriminator is `type: "file"` but
# the payload lives on `base64`/`mime_type` siblings.
{
"type": "file",
"id": "lc_1",
"base64": "JVBERi0xLjQK",
"mime_type": "application/pdf",
"extras": {"file_format": "application/pdf"},
},
],
}
]
assert get_file_ids_from_messages(messages) == []
def test_get_file_ids_from_messages_still_extracts_from_openai_shape():
"""Well-formed OpenAI file blocks still yield their file_id."""
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "what is this?"},
{"type": "file", "file": {"file_id": "file-abc"}},
],
}
]
assert get_file_ids_from_messages(messages) == ["file-abc"]
def test_get_file_ids_from_messages_mixed_shapes():
"""Mixed OpenAI and non-OpenAI file blocks: extract from the former,
ignore the latter."""
messages = [
{
"role": "user",
"content": [
{"type": "file", "file": {"file_id": "file-keep"}},
{
"type": "file",
"id": "lc_2",
"base64": "AAA",
"mime_type": "application/pdf",
},
],
}
]
assert get_file_ids_from_messages(messages) == ["file-keep"]
def test_get_file_ids_from_messages_file_field_not_dict():
"""`file` set to a non-dict value (e.g. stringified payload) must not crash."""
messages = [
{
"role": "user",
"content": [
{"type": "file", "file": "unexpectedly-a-string"},
],
}
]
assert get_file_ids_from_messages(messages) == []
def test_update_messages_with_model_file_ids_skips_non_openai_file_blocks():
"""`update_messages_with_model_file_ids` is also called on user content
before provider dispatch. It must tolerate non-OpenAI file blocks the same
way."""
langchain_v1_block = {
"type": "file",
"id": "lc_3",
"base64": "AAA",
"mime_type": "application/pdf",
}
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "hello"},
langchain_v1_block,
],
}
]
updated = update_messages_with_model_file_ids(messages, "model-1", {})
# Messages pass through unchanged when there is no `file` sub-dict to remap.
assert updated == messages
@@ -2162,16 +2162,19 @@ class TestTranslateAnthropicOutputFormatToOpenAI:
assert sorted(schema["required"]) == ["age", "email", "name"]
def test_invalid_output_format_returns_none(self):
assert (
self.adapter.translate_anthropic_output_format_to_openai("invalid") is None
)
assert (
self.adapter.translate_anthropic_output_format_to_openai({"type": "text"})
is None
)
assert (
self.adapter.translate_anthropic_output_format_to_openai(
{"type": "json_schema"}
)
is None
)
assert self.adapter.translate_anthropic_output_format_to_openai("invalid") is None
assert self.adapter.translate_anthropic_output_format_to_openai({"type": "text"}) is None
assert self.adapter.translate_anthropic_output_format_to_openai({"type": "json_schema"}) is None
def test_translate_anthropic_tool_choice_none():
"""
Regression test for issue #24443.
tool_choice={"type": "none"} should be translated to "none" for OpenAI format,
not raise a ValueError.
"""
adapter = LiteLLMAnthropicMessagesAdapter()
result = adapter.translate_anthropic_tool_choice_to_openai({"type": "none"})
assert result == "none"
@@ -0,0 +1,173 @@
"""
Tests for reasoning_auto_summary support on the native /v1/messages handler.
When reasoning_auto_summary is enabled (via litellm.reasoning_auto_summary or
LITELLM_REASONING_AUTO_SUMMARY env var), the handler injects
thinking.display = "summarized" into the request params for active thinking
modes (type="enabled" or type="adaptive").
"""
import os
import sys
import pytest
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.abspath("../../../../.."))
import litellm
from litellm.llms.anthropic.experimental_pass_through.messages.handler import (
anthropic_messages_handler,
)
def _call_handler_and_capture_optional_params(thinking=None, **extra_kwargs):
"""
Call anthropic_messages_handler with an Anthropic model and capture the
anthropic_messages_optional_request_params dict passed to
base_llm_http_handler.anthropic_messages_handler.
Returns the captured dict.
"""
captured = {}
with patch(
"litellm.llms.anthropic.experimental_pass_through.messages.handler."
"base_llm_http_handler"
) as mock_handler, patch(
"litellm.llms.anthropic.experimental_pass_through.messages.handler."
"ProviderConfigManager"
) as mock_pcm:
# Make get_provider_anthropic_messages_config return a non-None config
# so the handler takes the native Anthropic path
mock_pcm.get_provider_anthropic_messages_config.return_value = MagicMock()
mock_handler.anthropic_messages_handler.return_value = MagicMock()
kwargs = dict(extra_kwargs)
if thinking is not None:
kwargs["thinking"] = thinking
try:
anthropic_messages_handler(
max_tokens=1024,
messages=[{"role": "user", "content": "Hello"}],
model="claude-sonnet-4-20250514",
custom_llm_provider="anthropic",
api_key="test-key",
**kwargs,
)
except (ValueError, TypeError, AttributeError):
pass
if mock_handler.anthropic_messages_handler.called:
captured = mock_handler.anthropic_messages_handler.call_args.kwargs.get(
"anthropic_messages_optional_request_params", {}
)
return captured
class TestReasoningAutoSummaryMessages:
"""Tests for thinking.display injection on native /v1/messages handler."""
def test_adaptive_thinking_gets_display_summarized(self):
"""reasoning_auto_summary=True + thinking.type='adaptive' -> display='summarized'."""
with patch.object(litellm, "reasoning_auto_summary", True):
params = _call_handler_and_capture_optional_params(
thinking={"type": "adaptive", "budget_tokens": 5000}
)
thinking = params.get("thinking", {})
assert thinking.get("display") == "summarized"
assert thinking.get("type") == "adaptive"
assert thinking.get("budget_tokens") == 5000
def test_enabled_thinking_gets_display_summarized(self):
"""reasoning_auto_summary=True + thinking.type='enabled' -> display='summarized'."""
with patch.object(litellm, "reasoning_auto_summary", True):
params = _call_handler_and_capture_optional_params(
thinking={"type": "enabled", "budget_tokens": 10000}
)
thinking = params.get("thinking", {})
assert thinking.get("display") == "summarized"
assert thinking.get("type") == "enabled"
def test_disabled_thinking_no_display(self):
"""reasoning_auto_summary=True + thinking.type='disabled' -> display NOT set."""
with patch.object(litellm, "reasoning_auto_summary", True):
params = _call_handler_and_capture_optional_params(
thinking={"type": "disabled"}
)
thinking = params.get("thinking", {})
assert "display" not in thinking
def test_no_injection_when_flag_false(self):
"""reasoning_auto_summary=False + active thinking -> display NOT set."""
with patch.object(litellm, "reasoning_auto_summary", False):
params = _call_handler_and_capture_optional_params(
thinking={"type": "enabled", "budget_tokens": 10000}
)
thinking = params.get("thinking", {})
assert "display" not in thinking
def test_no_thinking_param_no_crash(self):
"""reasoning_auto_summary=True but no thinking param -> nothing changes."""
with patch.object(litellm, "reasoning_auto_summary", True):
params = _call_handler_and_capture_optional_params()
thinking = params.get("thinking")
if thinking is not None:
assert "display" not in thinking
def test_env_var_enables_auto_summary(self):
"""LITELLM_REASONING_AUTO_SUMMARY=true env var enables the feature."""
with patch.object(litellm, "reasoning_auto_summary", False), patch.dict(
os.environ, {"LITELLM_REASONING_AUTO_SUMMARY": "true"}
):
params = _call_handler_and_capture_optional_params(
thinking={"type": "adaptive", "budget_tokens": 5000}
)
thinking = params.get("thinking", {})
assert thinking.get("display") == "summarized"
def test_existing_display_summarized_preserved(self):
"""User already passes display='summarized' -> preserved as-is."""
with patch.object(litellm, "reasoning_auto_summary", True):
params = _call_handler_and_capture_optional_params(
thinking={
"type": "enabled",
"budget_tokens": 10000,
"display": "summarized",
}
)
thinking = params.get("thinking", {})
assert thinking.get("display") == "summarized"
def test_existing_display_summarized_without_flag(self):
"""User passes display='summarized' + flag=False -> preserved as-is."""
with patch.object(litellm, "reasoning_auto_summary", False):
params = _call_handler_and_capture_optional_params(
thinking={
"type": "enabled",
"budget_tokens": 10000,
"display": "summarized",
}
)
thinking = params.get("thinking", {})
assert thinking.get("display") == "summarized"
def test_omitted_overridden_to_summarized(self):
"""User passes display='omitted' + reasoning_auto_summary=True -> overridden.
Documents current behavior: the code unconditionally sets
display='summarized' when auto_summary is enabled and thinking is active,
regardless of any pre-existing display value.
"""
with patch.object(litellm, "reasoning_auto_summary", True):
params = _call_handler_and_capture_optional_params(
thinking={
"type": "enabled",
"budget_tokens": 10000,
"display": "omitted",
}
)
thinking = params.get("thinking", {})
assert thinking.get("display") == "summarized"
@@ -0,0 +1,287 @@
"""
Tests for reasoning effort capability fields and normalize_reasoning_effort_value.
Covers:
- Commit 1: get_model_info returns supports_minimal/supports_max fields
- Commit 2: Model registry entries have correct reasoning effort fields
- Commit 3: normalize_reasoning_effort_value degradation chains + adapter translation
"""
import json
import os
from typing import Any, Dict, Optional
from unittest.mock import patch
import pytest
from litellm.llms.anthropic.experimental_pass_through.utils import (
normalize_reasoning_effort_value,
)
from litellm.utils import get_model_info
def _load_model_registry() -> Dict[str, Any]:
"""Load the root model_prices_and_context_window.json."""
json_path = os.path.join(
os.path.dirname(__file__),
"../../../../../model_prices_and_context_window.json",
)
with open(json_path) as f:
return json.load(f)
# ---------------------------------------------------------------------------
# Commit 1: get_model_info returns supports_minimal and supports_max fields
# ---------------------------------------------------------------------------
class TestGetModelInfoReasoningEffortFields:
"""get_model_info should expose supports_minimal_reasoning_effort and
supports_max_reasoning_effort from the model registry."""
def test_opus_4_6_has_supports_minimal(self):
info = get_model_info("claude-opus-4-6")
assert "supports_minimal_reasoning_effort" in info
def test_opus_4_6_has_supports_max(self):
info = get_model_info("claude-opus-4-6")
assert "supports_max_reasoning_effort" in info
def test_opus_4_7_has_supports_minimal(self):
info = get_model_info("claude-opus-4-7")
assert "supports_minimal_reasoning_effort" in info
def test_opus_4_7_has_supports_max(self):
info = get_model_info("claude-opus-4-7")
assert "supports_max_reasoning_effort" in info
# ---------------------------------------------------------------------------
# Commit 2: JSON registry has correct reasoning effort fields
# ---------------------------------------------------------------------------
class TestModelRegistryReasoningEffortFields:
"""Verify specific models have the expected reasoning effort capability
values in the JSON registry file."""
@pytest.fixture(autouse=True)
def _load_registry(self):
self.registry = _load_model_registry()
def test_opus_4_7_supports_max(self):
entry = self.registry["claude-opus-4-7"]
assert entry.get("supports_max_reasoning_effort") is True
def test_opus_4_6_supports_max(self):
entry = self.registry["claude-opus-4-6"]
assert entry.get("supports_max_reasoning_effort") is True
def test_opus_4_7_supports_minimal(self):
entry = self.registry["claude-opus-4-7"]
assert entry.get("supports_minimal_reasoning_effort") is True
def test_opus_4_6_supports_minimal(self):
entry = self.registry["claude-opus-4-6"]
assert entry.get("supports_minimal_reasoning_effort") is True
def test_sonnet_4_6_supports_minimal(self):
entry = self.registry["anthropic.claude-sonnet-4-6"]
assert entry.get("supports_minimal_reasoning_effort") is True
def test_bedrock_opus_4_7_supports_max(self):
entry = self.registry["anthropic.claude-opus-4-7"]
assert entry.get("supports_max_reasoning_effort") is True
assert entry.get("supports_minimal_reasoning_effort") is True
def test_vertex_opus_4_7_supports_max(self):
entry = self.registry["vertex_ai/claude-opus-4-7"]
assert entry.get("supports_max_reasoning_effort") is True
assert entry.get("supports_minimal_reasoning_effort") is True
def test_vertex_opus_4_6_supports_max(self):
entry = self.registry["vertex_ai/claude-opus-4-6"]
assert entry.get("supports_max_reasoning_effort") is True
assert entry.get("supports_minimal_reasoning_effort") is True
def test_azure_ai_opus_4_6_supports_minimal(self):
entry = self.registry["azure_ai/claude-opus-4-6"]
assert entry.get("supports_minimal_reasoning_effort") is True
def test_azure_ai_opus_4_7_supports_max(self):
entry = self.registry["azure_ai/claude-opus-4-7"]
assert entry.get("supports_max_reasoning_effort") is True
assert entry.get("supports_minimal_reasoning_effort") is True
# ---------------------------------------------------------------------------
# Commit 3: normalize_reasoning_effort_value
# ---------------------------------------------------------------------------
def _mock_model_info(**flags):
"""Return a mock model_info dict with given capability flags."""
return flags
class TestNormalizeReasoningEffortValue:
"""Test degradation chains for normalize_reasoning_effort_value."""
# --- "max" degradation chain ---
def test_max_stays_max_when_supported(self):
with patch(
"litellm.utils.get_model_info",
return_value=_mock_model_info(
supports_max_reasoning_effort=True,
supports_xhigh_reasoning_effort=True,
),
):
assert normalize_reasoning_effort_value("max", model="test") == "max"
def test_max_degrades_to_xhigh(self):
with patch(
"litellm.utils.get_model_info",
return_value=_mock_model_info(
supports_max_reasoning_effort=False,
supports_xhigh_reasoning_effort=True,
),
):
assert normalize_reasoning_effort_value("max", model="test") == "xhigh"
def test_max_degrades_to_high(self):
with patch(
"litellm.utils.get_model_info",
return_value=_mock_model_info(
supports_max_reasoning_effort=False,
supports_xhigh_reasoning_effort=False,
),
):
assert normalize_reasoning_effort_value("max", model="test") == "high"
# --- "xhigh" degradation chain ---
def test_xhigh_stays_xhigh_when_supported(self):
with patch(
"litellm.utils.get_model_info",
return_value=_mock_model_info(supports_xhigh_reasoning_effort=True),
):
assert normalize_reasoning_effort_value("xhigh", model="test") == "xhigh"
def test_xhigh_degrades_to_high(self):
with patch(
"litellm.utils.get_model_info",
return_value=_mock_model_info(supports_xhigh_reasoning_effort=False),
):
assert normalize_reasoning_effort_value("xhigh", model="test") == "high"
# --- "minimal" degradation chain ---
def test_minimal_stays_minimal_when_supported(self):
with patch(
"litellm.utils.get_model_info",
return_value=_mock_model_info(supports_minimal_reasoning_effort=True),
):
assert (
normalize_reasoning_effort_value("minimal", model="test") == "minimal"
)
def test_minimal_degrades_to_low(self):
with patch(
"litellm.utils.get_model_info",
return_value=_mock_model_info(supports_minimal_reasoning_effort=False),
):
assert normalize_reasoning_effort_value("minimal", model="test") == "low"
# --- passthrough values ---
def test_high_passes_through(self):
assert normalize_reasoning_effort_value("high", model="test") == "high"
def test_medium_passes_through(self):
assert normalize_reasoning_effort_value("medium", model="test") == "medium"
def test_low_passes_through(self):
assert normalize_reasoning_effort_value("low", model="test") == "low"
# --- exception fallback ---
def test_exception_fallback_uses_empty_model_info(self):
"""When get_model_info raises, treat model_info as {} (no capabilities)."""
with patch(
"litellm.utils.get_model_info",
side_effect=Exception("model not found"),
):
# "max" with no capabilities -> "high"
assert normalize_reasoning_effort_value("max", model="unknown") == "high"
# "minimal" with no capabilities -> "low"
assert normalize_reasoning_effort_value("minimal", model="unknown") == "low"
# ---------------------------------------------------------------------------
# Commit 3: Adapter translation — adaptive thinking + output_config.effort
# ---------------------------------------------------------------------------
class TestAdapterAdaptiveThinking:
"""Test that adaptive thinking type maps correctly through the adapters."""
def test_messages_adapter_adaptive_returns_medium_default(self):
"""Adaptive thinking returns 'medium' as default reasoning_effort."""
from litellm.llms.anthropic.experimental_pass_through.adapters.transformation import (
LiteLLMAnthropicMessagesAdapter,
)
adapter = LiteLLMAnthropicMessagesAdapter()
result = adapter.translate_anthropic_thinking_to_reasoning_effort(
{"type": "adaptive"}
)
assert result == "medium"
def test_messages_adapter_adaptive_overridden_by_output_config(self):
"""For adaptive thinking, output_config.effort overrides reasoning_effort."""
from litellm.llms.anthropic.experimental_pass_through.adapters.transformation import (
LiteLLMAnthropicMessagesAdapter,
)
from litellm.types.llms.anthropic import AnthropicMessagesRequest
adapter = LiteLLMAnthropicMessagesAdapter()
request = AnthropicMessagesRequest(
model="test-model",
messages=[{"role": "user", "content": "hello"}],
max_tokens=1024,
thinking={"type": "adaptive"},
output_config={"effort": "high"},
)
openai_kwargs, _ = adapter.translate_anthropic_to_openai(request)
# reasoning_effort should be set (either as string or dict with effort)
re = openai_kwargs.get("reasoning_effort")
if isinstance(re, dict):
assert re["effort"] == "high"
else:
assert re == "high"
def test_responses_adapter_adaptive_with_output_config(self):
"""Responses adapter: adaptive thinking + output_config.effort."""
from litellm.llms.anthropic.experimental_pass_through.responses_adapters.transformation import (
LiteLLMAnthropicToResponsesAPIAdapter,
)
result = LiteLLMAnthropicToResponsesAPIAdapter.translate_thinking_to_reasoning(
thinking={"type": "adaptive"},
output_config={"effort": "xhigh"},
)
assert result is not None
assert result["effort"] == "xhigh"
def test_responses_adapter_adaptive_default_medium(self):
"""Responses adapter: adaptive thinking without output_config defaults to medium."""
from litellm.llms.anthropic.experimental_pass_through.responses_adapters.transformation import (
LiteLLMAnthropicToResponsesAPIAdapter,
)
result = LiteLLMAnthropicToResponsesAPIAdapter.translate_thinking_to_reasoning(
thinking={"type": "adaptive"},
)
assert result is not None
assert result["effort"] == "medium"
@@ -0,0 +1,152 @@
"""
Regression tests for is_model_gpt_5_model() in both OpenAI and Azure GPT-5 config
classes.
Background
----------
In v1.82.3 a substring check was introduced::
return "gpt-5" in model and "gpt-5-chat" not in model
This inadvertently treated versioned chat models like ``gpt-5.3-chat`` and
``gpt-5.1-chat`` as *non*-GPT-5 models, because the string ``"gpt-5-chat"`` is
a substring of ``"gpt-5.3-chat"``. Those models were then routed through the
regular Azure chat path which does not suppress ``parallel_tool_calls``, causing
Azure to return ``finish_reason="stop"`` together with tool_calls and breaking
n8n AI-agent workflows.
There are two distinct families:
* **gpt-5-chat family** (``gpt-5-chat``, ``gpt-5-chat-latest``,
``gpt-5-chat-2025-08-07``, ) regular chat models that support ``temperature``
and ``tool_choice`` but NOT ``reasoning_effort``. Must NOT be on the GPT-5
reasoning path.
* **Versioned chat models** (``gpt-5.1-chat``, ``gpt-5.2-chat``,
``gpt-5.3-chat``, ) ARE GPT-5 reasoning models and must stay on the GPT-5
path.
The fix uses a prefix check (``startswith("gpt-5-chat")``) on the normalised model
name instead of a substring check, which correctly distinguishes the two families.
"""
import pytest
from litellm.llms.openai.chat.gpt_5_transformation import OpenAIGPT5Config
from litellm.llms.azure.chat.gpt_5_transformation import AzureOpenAIGPT5Config
# ---------------------------------------------------------------------------
# Parametrized fixtures
# ---------------------------------------------------------------------------
# Models that MUST be classified as GPT-5 (routed through GPT-5 reasoning path)
GPT5_MODELS = [
"gpt-5",
"gpt-5.1",
"gpt-5.2",
"gpt-5.3",
"gpt-5.4",
"gpt-5.5",
"gpt-5.1-chat", # versioned chat — THE KEY REGRESSION CASE
"gpt-5.2-chat", # versioned chat — also a regression case
"gpt-5.3-chat", # versioned chat — THE KEY REGRESSION CASE
"gpt-5.2-chat-latest", # versioned chat with date suffix
"gpt-5.1-codex",
"gpt-5.1-codex-mini",
"gpt-5.1-mini",
"gpt-5-nano",
"gpt-5-mini",
"gpt-5-codex",
]
# Models that must NOT be classified as GPT-5 (regular chat path)
NON_GPT5_MODELS = [
"gpt-5-chat", # gpt-5-chat family — regular chat path
"gpt-5-chat-latest", # gpt-5-chat family with alias suffix
"gpt-5-chat-2025-08-07", # gpt-5-chat family with date suffix
"gpt-4",
"gpt-4o",
"gpt-4-turbo",
"gpt-3.5-turbo",
"o1",
"o3",
"o3-mini",
]
# ---------------------------------------------------------------------------
# OpenAIGPT5Config
# ---------------------------------------------------------------------------
class TestOpenAIGPT5ConfigIsModelGpt5Model:
@pytest.mark.parametrize("model", GPT5_MODELS)
def test_gpt5_models_are_classified_as_gpt5(self, model: str):
assert OpenAIGPT5Config.is_model_gpt_5_model(
model
), f"Expected '{model}' to be classified as a GPT-5 model"
@pytest.mark.parametrize("model", NON_GPT5_MODELS)
def test_non_gpt5_models_are_not_classified_as_gpt5(self, model: str):
assert not OpenAIGPT5Config.is_model_gpt_5_model(
model
), f"Expected '{model}' NOT to be classified as a GPT-5 model"
def test_versioned_chat_models_are_not_excluded_by_prefix(self):
"""Core regression guard: gpt-5-chat prefix must not match versioned models."""
versioned_chat_models = ["gpt-5.1-chat", "gpt-5.2-chat", "gpt-5.3-chat"]
for model in versioned_chat_models:
assert OpenAIGPT5Config.is_model_gpt_5_model(
model
), f"Regression: '{model}' was incorrectly excluded from GPT-5 path"
def test_gpt5_chat_family_is_excluded(self):
"""gpt-5-chat family should stay on the regular chat path."""
for model in ["gpt-5-chat", "gpt-5-chat-latest", "gpt-5-chat-2025-08-07"]:
assert not OpenAIGPT5Config.is_model_gpt_5_model(
model
), f"Expected '{model}' (gpt-5-chat family) NOT to be on the GPT-5 path"
# ---------------------------------------------------------------------------
# AzureOpenAIGPT5Config
# ---------------------------------------------------------------------------
class TestAzureOpenAIGPT5ConfigIsModelGpt5Model:
@pytest.mark.parametrize("model", GPT5_MODELS)
def test_gpt5_models_are_classified_as_gpt5(self, model: str):
assert AzureOpenAIGPT5Config.is_model_gpt_5_model(
model
), f"Expected Azure '{model}' to be classified as a GPT-5 model"
@pytest.mark.parametrize("model", NON_GPT5_MODELS)
def test_non_gpt5_models_are_not_classified_as_gpt5(self, model: str):
assert not AzureOpenAIGPT5Config.is_model_gpt_5_model(
model
), f"Expected Azure '{model}' NOT to be classified as a GPT-5 model"
def test_versioned_chat_models_are_not_excluded_by_prefix(self):
"""Core regression guard: gpt-5-chat prefix must not match versioned models."""
versioned_chat_models = ["gpt-5.1-chat", "gpt-5.2-chat", "gpt-5.3-chat"]
for model in versioned_chat_models:
assert AzureOpenAIGPT5Config.is_model_gpt_5_model(
model
), f"Regression: Azure '{model}' was incorrectly excluded from GPT-5 path"
def test_gpt5_chat_family_is_excluded(self):
"""gpt-5-chat family should stay on the regular chat path."""
for model in ["gpt-5-chat", "gpt-5-chat-latest", "gpt-5-chat-2025-08-07"]:
assert not AzureOpenAIGPT5Config.is_model_gpt_5_model(
model
), f"Expected Azure '{model}' (gpt-5-chat family) NOT to be on the GPT-5 path"
def test_gpt5_series_routing_prefix_is_always_classified_as_gpt5(self):
"""Models using the gpt5_series/ manual-routing prefix must always match."""
series_models = ["gpt5_series/my-deployment", "gpt5_series/prod"]
for model in series_models:
assert AzureOpenAIGPT5Config.is_model_gpt_5_model(
model
), f"Azure '{model}' with gpt5_series/ prefix should be classified as GPT-5"
@@ -8,6 +8,7 @@ import sys
import pytest
from litellm.llms.ovhcloud.utils import OVHCloudException
from litellm.utils import get_optional_params
sys.path.insert(
0, os.path.abspath("../../../../..")
@@ -144,6 +145,38 @@ class TestOVHCloudConfig:
assert error.message == "Test error"
assert error.status_code == 400
@pytest.mark.parametrize(
"model",
[
"Meta-Llama-3_3-70B-Instruct",
"Meta-Llama-3_1-70B-Instruct",
"Mixtral-8x7B-Instruct-v0.1",
"gpt-oss-120b",
"some-model-not-in-the-cost-map",
],
)
def test_tools_not_filtered_by_static_model_map(self, model):
"""
OVHCloud AI Endpoints are OpenAI-compatible; tools/tool_choice must pass
through for any model. The server is responsible for rejecting unsupported
tool calls LiteLLM must not strip them based on a stale static catalog.
"""
params = get_optional_params(
model=model,
custom_llm_provider="ovhcloud",
tools=[
{
"type": "function",
"function": {"name": "x", "parameters": {}},
}
],
tool_choice="auto",
)
assert "tools" in params
assert "tool_choice" in params
def test_ovhcloud_integration():
import os
@@ -967,6 +967,94 @@ class TestMediaResolution:
assert "mediaResolution" not in result["generationConfig"]
# Tests for VideoMetadata support across all Gemini models (Issue #25474)
class TestVideoMetadataAllGeminiModels:
"""Tests that video_metadata (fps, start_offset, end_offset) works for all Gemini models"""
def _make_video_messages(self, video_metadata: dict) -> list:
return [
{
"role": "user",
"content": [
{"type": "text", "text": "Analyze this video"},
{
"type": "file",
"file": {
"file_id": "gs://bucket/video.mp4",
"format": "video/mp4",
"video_metadata": video_metadata,
},
},
],
}
]
def _get_file_part(self, contents: list) -> dict:
for part in contents[0]["parts"]:
if "file_data" in part:
return part
raise AssertionError("No file part found in contents")
def test_video_metadata_fps_gemini_2_5_flash(self):
"""Gemini 2.5 Flash: fps in video_metadata should be forwarded (Issue #25474)"""
messages = self._make_video_messages({"fps": 5})
contents = _gemini_convert_messages_with_history(
messages=messages, model="gemini-2.5-flash"
)
file_part = self._get_file_part(contents)
assert "video_metadata" in file_part
assert file_part["video_metadata"]["fps"] == 5
def test_video_metadata_fps_gemini_2_5_pro(self):
"""Gemini 2.5 Pro: fps in video_metadata should be forwarded (Issue #25474)"""
messages = self._make_video_messages({"fps": 10})
contents = _gemini_convert_messages_with_history(
messages=messages, model="gemini-2.5-pro"
)
file_part = self._get_file_part(contents)
assert "video_metadata" in file_part
assert file_part["video_metadata"]["fps"] == 10
def test_video_metadata_offsets_gemini_2_5_flash(self):
"""Gemini 2.5 Flash: start_offset/end_offset converted to camelCase (Issue #25474)"""
messages = self._make_video_messages(
{"start_offset": "5s", "end_offset": "30s"}
)
contents = _gemini_convert_messages_with_history(
messages=messages, model="gemini-2.5-flash"
)
file_part = self._get_file_part(contents)
assert "video_metadata" in file_part
vm = file_part["video_metadata"]
assert vm["startOffset"] == "5s"
assert vm["endOffset"] == "30s"
def test_video_metadata_all_fields_gemini_2_5_flash(self):
"""Gemini 2.5 Flash: all video_metadata fields forwarded correctly (Issue #25474)"""
messages = self._make_video_messages(
{"fps": 5, "start_offset": "10s", "end_offset": "60s"}
)
contents = _gemini_convert_messages_with_history(
messages=messages, model="gemini-2.5-flash"
)
file_part = self._get_file_part(contents)
assert "video_metadata" in file_part
vm = file_part["video_metadata"]
assert vm["fps"] == 5
assert vm["startOffset"] == "10s"
assert vm["endOffset"] == "60s"
def test_video_metadata_gemini_1_5_pro(self):
"""Gemini 1.5 Pro: video_metadata should also be forwarded (Issue #25474)"""
messages = self._make_video_messages({"fps": 2})
contents = _gemini_convert_messages_with_history(
messages=messages, model="gemini-1.5-pro"
)
file_part = self._get_file_part(contents)
assert "video_metadata" in file_part
assert file_part["video_metadata"]["fps"] == 2
def test_convert_tool_response_with_base64_image():
"""Test tool response with base64 data URI image."""
# Create a small test image (1x1 red pixel PNG)
@@ -3476,8 +3476,8 @@ def test_new_detail_levels():
assert file_part["media_resolution"] == {"level": "MEDIA_RESOLUTION_MEDIUM"}
def test_video_metadata_only_for_gemini_3():
"""Test that video_metadata is only applied for Gemini 3+ models (Issue #19026)"""
def test_video_metadata_supported_for_all_gemini_models():
"""Test that video_metadata is applied for all Gemini models (Issue #25474)"""
from litellm.llms.vertex_ai.gemini.transformation import (
_gemini_convert_messages_with_history,
)
@@ -3499,39 +3499,29 @@ def test_video_metadata_only_for_gemini_3():
}
]
# Test with Gemini 1.5 (should not have video_metadata or media_resolution)
contents_1_5 = _gemini_convert_messages_with_history(
messages=messages, model="gemini-1.5-pro"
)
for model in ["gemini-1.5-pro", "gemini-2.5-flash", "gemini-2.5-pro", "gemini-3-pro-preview"]:
contents = _gemini_convert_messages_with_history(messages=messages, model=model)
file_part_1_5 = None
for part in contents_1_5[0]["parts"]:
if "file_data" in part:
file_part_1_5 = part
break
file_part = None
for part in contents[0]["parts"]:
if "file_data" in part:
file_part = part
break
assert file_part_1_5 is not None
assert (
"media_resolution" not in file_part_1_5
), "Gemini 1.5 should not have media_resolution"
assert (
"video_metadata" not in file_part_1_5
), "Gemini 1.5 should not have video_metadata"
assert file_part is not None, f"{model}: file part should exist"
assert "video_metadata" in file_part, f"{model}: video_metadata should be present"
assert file_part["video_metadata"]["fps"] == 5, f"{model}: fps should be 5"
# Test with Gemini 3 (should have both)
contents_3 = _gemini_convert_messages_with_history(
messages=messages, model="gemini-3-pro-preview"
)
# Per-part media_resolution is Gemini 3+ only; 2.x uses generation_config global
for model in ["gemini-3-pro-preview"]:
contents = _gemini_convert_messages_with_history(messages=messages, model=model)
file_part = next(p for p in contents[0]["parts"] if "file_data" in p)
assert "media_resolution" in file_part, f"{model}: media_resolution should be present"
file_part_3 = None
for part in contents_3[0]["parts"]:
if "file_data" in part:
file_part_3 = part
break
assert file_part_3 is not None
assert "media_resolution" in file_part_3, "Gemini 3 should have media_resolution"
assert "video_metadata" in file_part_3, "Gemini 3 should have video_metadata"
for model in ["gemini-1.5-pro", "gemini-2.5-flash", "gemini-2.5-pro"]:
contents = _gemini_convert_messages_with_history(messages=messages, model=model)
file_part = next(p for p in contents[0]["parts"] if "file_data" in p)
assert "media_resolution" not in file_part, f"{model}: per-part media_resolution should not be set"
def test_chunk_parser_handles_prompt_feedback_block():
@@ -5,6 +5,7 @@ This test suite ensures that all Vertex AI endpoints properly handle the 'global
which uses a different URL format than regional endpoints.
Regional: https://{region}-aiplatform.googleapis.com/...
Multi-region: https://aiplatform.{geo}.rep.googleapis.com/...
Global: https://aiplatform.googleapis.com/...
"""
@@ -30,6 +31,8 @@ class TestVertexBaseURL:
("europe-west1", "https://europe-west1-aiplatform.googleapis.com"),
("asia-northeast1", "https://asia-northeast1-aiplatform.googleapis.com"),
("global", "https://aiplatform.googleapis.com"),
("us", "https://aiplatform.us.rep.googleapis.com"),
("eu", "https://aiplatform.eu.rep.googleapis.com"),
],
)
def test_get_vertex_base_url(self, vertex_location, expected_base_url):
@@ -450,3 +450,193 @@ async def test_semantic_filter_hook_skips_no_tools():
# Should return None (no modification)
assert result is None, "Hook should skip requests without tools"
print("✅ Hook correctly skips requests without tools")
class TestGetToolsByNames:
"""
Regression coverage for SemanticMCPToolFilter._get_tools_by_names
name-matching behavior (issue #26078).
The canonical name stored in the router is what the proxy's MCP
registry emits (e.g. ``fc_web_search-firecrawl_scrape``). Some MCP
clients notably opencode wrap every tool name with their own
additive namespace prefix before sending it back in ``tools[]``, so
the incoming name is ``litellm_fc_web_search-firecrawl_scrape``.
Exact-equality matching against the canonical dropped every such
tool, the proxy forwarded ``tools: []`` with ``tool_choice: auto``,
and strict upstream providers returned 400.
"""
def _make_filter(self):
from litellm.proxy._experimental.mcp_server.semantic_tool_filter import (
SemanticMCPToolFilter,
)
return SemanticMCPToolFilter(
embedding_model="text-embedding-3-small",
litellm_router_instance=Mock(),
top_k=5,
similarity_threshold=0.3,
enabled=True,
)
def test_exact_match_unchanged(self):
"""Incoming name equals canonical — the historical path still works."""
filter_instance = self._make_filter()
available_tools = [
{"name": "get_weather", "description": "fetch weather"},
{"name": "send_email", "description": "send mail"},
]
matched = filter_instance._get_tools_by_names(
["send_email"], available_tools
)
assert len(matched) == 1
assert matched[0]["name"] == "send_email"
def test_client_prefix_with_underscore_separator(self):
"""Client wraps canonical with ``<alias>_`` (opencode pattern)."""
filter_instance = self._make_filter()
canonical = "fc_web_search-firecrawl_scrape"
client_name = "litellm_" + canonical
available_tools = [{"name": client_name, "description": "scrape"}]
matched = filter_instance._get_tools_by_names(
[canonical], available_tools
)
assert len(matched) == 1
# Must return the incoming tool unchanged so the client-facing
# name survives, otherwise tool-call round-trips break client-side.
assert matched[0]["name"] == client_name
def test_client_prefix_with_dash_separator(self):
"""Some clients use dash as alias separator; accept that too."""
filter_instance = self._make_filter()
canonical = "weather_svc-get_weather"
available_tools = [
{"name": "mcp-" + canonical, "description": "weather"}
]
matched = filter_instance._get_tools_by_names(
[canonical], available_tools
)
assert len(matched) == 1
assert matched[0]["name"] == "mcp-" + canonical
def test_suffix_without_separator_does_not_match(self):
"""
A bare-substring suffix must not match ``rain_gear`` is not a
namespaced version of canonical ``ear`` and the user would be
surprised to see it selected.
"""
filter_instance = self._make_filter()
available_tools = [{"name": "rain_gear", "description": "raincoat"}]
matched = filter_instance._get_tools_by_names(["ear"], available_tools)
assert matched == []
def test_exact_match_preferred_over_prefixed(self):
"""
When both a bare canonical and a client-prefixed variant are
present, the bare one wins so ordering is stable.
"""
filter_instance = self._make_filter()
canonical = "search"
available_tools = [
{"name": canonical, "description": "plain"},
{"name": "litellm_" + canonical, "description": "wrapped"},
]
matched = filter_instance._get_tools_by_names(
[canonical], available_tools
)
assert len(matched) == 1
assert matched[0]["name"] == canonical
def test_same_tool_not_returned_twice(self):
"""
Two distinct canonicals that both suffix-match the same incoming
tool must not produce a duplicate in the output list.
``fs-read_file`` and ``api-fs-read_file`` are both valid
separator-anchored suffixes of ``litellm_api-fs-read_file``.
"""
filter_instance = self._make_filter()
available_tools = [
{"name": "litellm_api-fs-read_file", "description": "read"}
]
matched = filter_instance._get_tools_by_names(
["fs-read_file", "api-fs-read_file"], available_tools
)
assert len(matched) == 1
def test_suffix_fallback_prefers_shortest_candidate(self):
"""
When no exact match exists and several incoming tools
suffix-match the same canonical, the one closest in length to
the canonical (i.e. the least-wrapped) should be chosen.
"""
filter_instance = self._make_filter()
canonical = "svc-search"
available_tools = [
{"name": "my_tag_" + canonical, "description": "tag search"},
{"name": "my_" + canonical, "description": "plain search"},
]
matched = filter_instance._get_tools_by_names(
[canonical], available_tools
)
assert len(matched) == 1
assert matched[0]["name"] == "my_" + canonical
def test_ordering_follows_router_output(self):
"""Returned tools follow the order the semantic router chose."""
filter_instance = self._make_filter()
available_tools = [
{"name": "litellm_fs-read", "description": "read"},
{"name": "litellm_fs-write", "description": "write"},
{"name": "litellm_fs-delete", "description": "delete"},
]
matched = filter_instance._get_tools_by_names(
["fs-write", "fs-delete", "fs-read"], available_tools
)
names = [t["name"] for t in matched]
assert names == [
"litellm_fs-write",
"litellm_fs-delete",
"litellm_fs-read",
]
def test_does_not_collide_with_local_function_on_unprefixed_canonical(self):
"""
Guard against the collision @krrish-berri-2 flagged on #26117:
if the canonical name from the router is not server-prefixed
(i.e. does not contain ``MCP_TOOL_PREFIX_SEPARATOR``), suffix
matching must not kick in. Otherwise an unrelated local user
function whose name happens to end in the canonical substring
would be spuriously selected.
"""
filter_instance = self._make_filter()
available_tools = [
{
"name": "my_firecrawl_scrape",
"description": "unrelated local function",
},
]
matched = filter_instance._get_tools_by_names(
["firecrawl_scrape"], # no MCP_TOOL_PREFIX_SEPARATOR in canonical
available_tools,
)
assert matched == []
@@ -2126,3 +2126,192 @@ class TestGuardrailModificationCheck:
"""Unparseable strings should not trigger a 403 — they have no keys."""
self._call({"metadata": "not-json"})
self._call({"metadata": '"just a string"'})
@pytest.mark.asyncio
async def test_team_member_budget_check_falls_back_to_team_default_budget_id():
"""When a member's TeamMembership has no linked budget row, the check
should fall back to team.metadata["team_member_budget_id"] and still
enforce the cap. Pre-fix, this path silently skipped enforcement."""
from litellm.caching.dual_cache import DualCache
from litellm.proxy._types import LiteLLM_TeamMembership
from litellm.proxy.utils import ProxyLogging
team_object = LiteLLM_TeamTable(
team_id="test-team",
metadata={"team_member_budget_id": "budget-default"},
)
user_object = LiteLLM_UserTable(user_id="test-user")
valid_token = UserAPIKeyAuth(
token="test-token",
user_id="test-user",
team_id="test-team",
)
# Membership row without an attached budget.
team_membership = LiteLLM_TeamMembership(
user_id="test-user",
team_id="test-team",
spend=0.0,
budget_id=None,
litellm_budget_table=None,
)
proxy_logging_obj = ProxyLogging(user_api_key_cache=None)
fake_budget_row = MagicMock()
fake_budget_row.max_budget = 50.0
fake_budget_row.dict = MagicMock(
return_value={"budget_id": "budget-default", "max_budget": 50.0}
)
prisma_client = MagicMock()
prisma_client.db.litellm_budgettable.find_unique = AsyncMock(
return_value=fake_budget_row
)
async def mock_get_current_spend(counter_key, fallback_spend):
if counter_key == "spend:team_member:test-user:test-team":
return 70.0
return fallback_spend
user_api_key_cache = DualCache()
with (
patch("litellm.proxy.proxy_server.get_current_spend", mock_get_current_spend),
patch(
"litellm.proxy.auth.auth_checks.get_team_membership",
new_callable=AsyncMock,
return_value=team_membership,
),
):
with pytest.raises(litellm.BudgetExceededError) as exc_info:
await _check_team_member_budget(
team_object=team_object,
user_object=user_object,
valid_token=valid_token,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
assert exc_info.value.current_cost == 70.0
assert exc_info.value.max_budget == 50.0
# First call did perform the fallback DB lookup.
prisma_client.db.litellm_budgettable.find_unique.assert_awaited_once()
# Second call hits the cached budget row, no additional prisma read.
prisma_client.db.litellm_budgettable.find_unique.reset_mock()
with (
patch("litellm.proxy.proxy_server.get_current_spend", mock_get_current_spend),
patch(
"litellm.proxy.auth.auth_checks.get_team_membership",
new_callable=AsyncMock,
return_value=team_membership,
),
):
with pytest.raises(litellm.BudgetExceededError) as second_exc_info:
await _check_team_member_budget(
team_object=team_object,
user_object=user_object,
valid_token=valid_token,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
# The cached $50 cap is still being applied (not a coincidental skip)
assert second_exc_info.value.current_cost == 70.0
assert second_exc_info.value.max_budget == 50.0
prisma_client.db.litellm_budgettable.find_unique.assert_not_awaited()
@pytest.mark.asyncio
async def test_team_member_budget_check_per_member_override_wins_over_team_default():
"""If a member has a per-member budget AND the team carries a
team_member_budget_id default, the per-member value wins and the
fallback prisma lookup is never performed."""
from litellm.caching.dual_cache import DualCache
from litellm.proxy._types import LiteLLM_BudgetTable, LiteLLM_TeamMembership
from litellm.proxy.utils import ProxyLogging
team_object = LiteLLM_TeamTable(
team_id="test-team",
metadata={"team_member_budget_id": "budget-default"},
)
user_object = LiteLLM_UserTable(user_id="test-user")
valid_token = UserAPIKeyAuth(
token="test-token",
user_id="test-user",
team_id="test-team",
)
team_membership = LiteLLM_TeamMembership(
user_id="test-user",
team_id="test-team",
spend=0.0,
budget_id="budget-override",
litellm_budget_table=LiteLLM_BudgetTable(max_budget=200.0),
)
proxy_logging_obj = ProxyLogging(user_api_key_cache=None)
# Team-default row resolves to $50. If the fallback fired (it must
# not here), spend $70 would exceed that $50 cap and raise.
fake_budget_row = MagicMock()
fake_budget_row.max_budget = 50.0
prisma_client = MagicMock()
prisma_client.db.litellm_budgettable.find_unique = AsyncMock(
return_value=fake_budget_row
)
mocked_spend = 70.0
async def mock_get_current_spend(counter_key, fallback_spend):
if counter_key == "spend:team_member:test-user:test-team":
return mocked_spend
return fallback_spend
# 1. spend ($70) < per-member cap ($200) → no raise, no fallback lookup.
with (
patch("litellm.proxy.proxy_server.get_current_spend", mock_get_current_spend),
patch(
"litellm.proxy.auth.auth_checks.get_team_membership",
new_callable=AsyncMock,
return_value=team_membership,
),
):
await _check_team_member_budget(
team_object=team_object,
user_object=user_object,
valid_token=valid_token,
prisma_client=prisma_client,
user_api_key_cache=DualCache(),
proxy_logging_obj=proxy_logging_obj,
)
prisma_client.db.litellm_budgettable.find_unique.assert_not_awaited()
# 2. Now push spend above the per-member cap ($200). Must raise with
# max_budget=200 to prove the per-member cap is the value being
# enforced (not just that enforcement silently skipped).
mocked_spend = 250.0
with (
patch("litellm.proxy.proxy_server.get_current_spend", mock_get_current_spend),
patch(
"litellm.proxy.auth.auth_checks.get_team_membership",
new_callable=AsyncMock,
return_value=team_membership,
),
):
with pytest.raises(litellm.BudgetExceededError) as exc_info:
await _check_team_member_budget(
team_object=team_object,
user_object=user_object,
valid_token=valid_token,
prisma_client=prisma_client,
user_api_key_cache=DualCache(),
proxy_logging_obj=proxy_logging_obj,
)
assert exc_info.value.current_cost == 250.0
assert exc_info.value.max_budget == 200.0
@@ -1,7 +1,9 @@
import asyncio
import json
import os
import sys
import time
import types
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List
from unittest.mock import AsyncMock, MagicMock
@@ -696,9 +698,9 @@ def test_reset_budget_resets_endusers_with_null_budget_id(
# Both end users should have been reset
updated = mock_prisma_client.updated_data["enduser"]
assert len(updated) == 2, (
f"Expected 2 endusers reset (1 explicit + 1 implicit), got {len(updated)}"
)
assert (
len(updated) == 2
), f"Expected 2 endusers reset (1 explicit + 1 implicit), got {len(updated)}"
user_ids = {u.user_id for u in updated}
assert "enduser-explicit" in user_ids
@@ -819,3 +821,231 @@ def test_reset_budget_for_team_members_preserves_total_spend():
assert call_kwargs["where"]["budget_id"]["in"] == ["budget-1"]
assert call_kwargs["data"] == {"spend": 0}
assert "total_spend" not in call_kwargs["data"]
# ---------------------------------------------------------------------------
# reset_budget_windows (per-key / per-team concurrent window resets)
# ---------------------------------------------------------------------------
def _make_reset_budget_windows_job(
monkeypatch,
key_rows: List[Dict[str, Any]],
team_rows: List[Dict[str, Any]],
):
"""Build a ResetBudgetJob with a fully-mocked prisma client and a fake
`litellm.proxy.proxy_server` module exposing a stub `spend_counter_cache`.
Returns (job, prisma_client_mock, spend_counter_cache_mock).
"""
prisma_client = MagicMock()
async def fake_query_raw(query: str, *args, **kwargs):
# Dispatch by table name in the SQL so a single stub covers both calls.
if '"LiteLLM_VerificationToken"' in query:
return key_rows
if '"LiteLLM_TeamTable"' in query:
return team_rows
raise AssertionError(f"Unexpected query_raw call: {query}")
prisma_client.db.query_raw = AsyncMock(side_effect=fake_query_raw)
prisma_client.db.litellm_verificationtoken.update = AsyncMock(return_value=None)
prisma_client.db.litellm_teamtable.update = AsyncMock(return_value=None)
# Stub out litellm.proxy.proxy_server so the in-function
# `from litellm.proxy.proxy_server import spend_counter_cache` resolves
# without importing the real (heavy) module.
spend_counter_cache = MagicMock()
spend_counter_cache.in_memory_cache.set_cache = MagicMock()
spend_counter_cache.redis_cache = None # skip the async redis branch
fake_module = types.ModuleType("litellm.proxy.proxy_server")
fake_module.spend_counter_cache = spend_counter_cache
monkeypatch.setitem(sys.modules, "litellm.proxy.proxy_server", fake_module)
job = ResetBudgetJob(proxy_logging_obj=MagicMock(), prisma_client=prisma_client)
return job, prisma_client, spend_counter_cache
def test_reset_budget_windows_uses_is_not_null_filter(monkeypatch):
"""Regression guard for the Prisma client limitation documented in
RobertCraigie/prisma-client-py#714: `{"not": None}` on a `Json?` column
raises `MissingRequiredValueError`. We work around it by using `query_raw`
with `IS NOT NULL`. If someone reverts to the ORM filter, this test fails.
"""
job, prisma_client, _ = _make_reset_budget_windows_job(
monkeypatch, key_rows=[], team_rows=[]
)
asyncio.run(job.reset_budget_windows())
queries = [call.args[0] for call in prisma_client.db.query_raw.await_args_list]
assert len(queries) == 2, queries
key_query, team_query = queries
assert '"LiteLLM_VerificationToken"' in key_query
assert "budget_limits IS NOT NULL" in key_query
assert '"LiteLLM_TeamTable"' in team_query
assert "budget_limits IS NOT NULL" in team_query
def test_reset_budget_windows_resets_expired_key_window(monkeypatch):
"""A key whose window's `reset_at` has passed gets an update with a new
`reset_at` in the future, and the in-memory spend counter is cleared."""
now = datetime.utcnow()
expired = (now - timedelta(minutes=5)).isoformat() + "Z"
key_rows = [
{
"token": "sk-expired",
"budget_limits": [{"budget_duration": "1d", "reset_at": expired}],
}
]
job, prisma_client, spend_counter_cache = _make_reset_budget_windows_job(
monkeypatch, key_rows=key_rows, team_rows=[]
)
asyncio.run(job.reset_budget_windows())
# Update should have been called exactly once with the expired token.
prisma_client.db.litellm_verificationtoken.update.assert_awaited_once()
call_kwargs = prisma_client.db.litellm_verificationtoken.update.await_args.kwargs
assert call_kwargs["where"] == {"token": "sk-expired"}
# The `budget_limits` payload is re-serialized JSON with a bumped reset_at.
written_windows = json.loads(call_kwargs["data"]["budget_limits"])
assert len(written_windows) == 1
new_reset_at = datetime.fromisoformat(
written_windows[0]["reset_at"].replace("Z", "+00:00")
).replace(tzinfo=None)
assert new_reset_at > now
# The spend counter for this key+window was cleared.
spend_counter_cache.in_memory_cache.set_cache.assert_any_call(
key="spend:key:sk-expired:window:1d", value=0.0
)
def test_reset_budget_windows_skips_unexpired_key_window(monkeypatch):
"""If `reset_at` is in the future, no write should happen for that key."""
now = datetime.utcnow()
future = (now + timedelta(hours=1)).isoformat() + "Z"
key_rows = [
{
"token": "sk-future",
"budget_limits": [{"budget_duration": "1d", "reset_at": future}],
}
]
job, prisma_client, _ = _make_reset_budget_windows_job(
monkeypatch, key_rows=key_rows, team_rows=[]
)
asyncio.run(job.reset_budget_windows())
prisma_client.db.litellm_verificationtoken.update.assert_not_awaited()
def test_reset_budget_windows_resets_expired_team_window(monkeypatch):
"""Same as the key test, but for teams."""
now = datetime.utcnow()
expired = (now - timedelta(minutes=1)).isoformat() + "Z"
team_rows = [
{
"team_id": "team-expired",
"budget_limits": [{"budget_duration": "30d", "reset_at": expired}],
}
]
job, prisma_client, spend_counter_cache = _make_reset_budget_windows_job(
monkeypatch, key_rows=[], team_rows=team_rows
)
asyncio.run(job.reset_budget_windows())
prisma_client.db.litellm_teamtable.update.assert_awaited_once()
call_kwargs = prisma_client.db.litellm_teamtable.update.await_args.kwargs
assert call_kwargs["where"] == {"team_id": "team-expired"}
assert "budget_limits" in call_kwargs["data"]
spend_counter_cache.in_memory_cache.set_cache.assert_any_call(
key="spend:team:team-expired:window:30d", value=0.0
)
def test_reset_budget_windows_handles_string_budget_limits(monkeypatch):
"""Defensive: if `query_raw` returns `budget_limits` as a JSON-encoded
string (driver-dependent), the code still parses and resets it.
"""
now = datetime.utcnow()
expired = (now - timedelta(minutes=1)).isoformat() + "Z"
key_rows = [
{
"token": "sk-string-limits",
"budget_limits": json.dumps(
[{"budget_duration": "1d", "reset_at": expired}]
),
}
]
job, prisma_client, _ = _make_reset_budget_windows_job(
monkeypatch, key_rows=key_rows, team_rows=[]
)
asyncio.run(job.reset_budget_windows())
prisma_client.db.litellm_verificationtoken.update.assert_awaited_once()
def test_reset_budget_windows_skips_row_with_empty_budget_limits(monkeypatch):
"""A row whose `budget_limits` comes back as an empty/falsy payload
(shouldn't happen given the WHERE filter, but we guard anyway) must not
trigger an update or crash the loop."""
key_rows = [
{"token": "sk-empty-list", "budget_limits": []},
{"token": "sk-empty-str", "budget_limits": ""},
]
job, prisma_client, _ = _make_reset_budget_windows_job(
monkeypatch, key_rows=key_rows, team_rows=[]
)
asyncio.run(job.reset_budget_windows())
prisma_client.db.litellm_verificationtoken.update.assert_not_awaited()
def test_reset_budget_windows_query_error_does_not_break_team_path(monkeypatch):
"""If the key query raises, the teams path still runs (and vice-versa).
Each side has its own try/except; this locks that in."""
now = datetime.utcnow()
expired = (now - timedelta(minutes=1)).isoformat() + "Z"
prisma_client = MagicMock()
async def fake_query_raw(query: str, *args, **kwargs):
if '"LiteLLM_VerificationToken"' in query:
raise RuntimeError("boom")
if '"LiteLLM_TeamTable"' in query:
return [
{
"team_id": "team-ok",
"budget_limits": [{"budget_duration": "1d", "reset_at": expired}],
}
]
raise AssertionError(query)
prisma_client.db.query_raw = AsyncMock(side_effect=fake_query_raw)
prisma_client.db.litellm_teamtable.update = AsyncMock(return_value=None)
spend_counter_cache = MagicMock()
spend_counter_cache.in_memory_cache.set_cache = MagicMock()
spend_counter_cache.redis_cache = None
fake_module = types.ModuleType("litellm.proxy.proxy_server")
fake_module.spend_counter_cache = spend_counter_cache
monkeypatch.setitem(sys.modules, "litellm.proxy.proxy_server", fake_module)
job = ResetBudgetJob(proxy_logging_obj=MagicMock(), prisma_client=prisma_client)
asyncio.run(job.reset_budget_windows()) # must not raise
prisma_client.db.litellm_teamtable.update.assert_awaited_once()
@@ -1,6 +1,7 @@
import os
import sys
import types
import json
from datetime import datetime, timedelta
from types import SimpleNamespace
from typing import List, Optional
@@ -1311,7 +1312,8 @@ class TestTemporaryMCPSessionEndpoints:
assert cache["temp-cache"].server is server
assert cache["temp-cache"].expires_at > datetime.utcnow()
def test_get_cached_temporary_mcp_server_prunes_expired_entries(self):
@pytest.mark.asyncio
async def test_get_cached_temporary_mcp_server_prunes_expired_entries(self):
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
_TemporaryMCPServerEntry,
get_cached_temporary_mcp_server,
@@ -1327,12 +1329,13 @@ class TestTemporaryMCPSessionEndpoints:
"litellm.proxy.management_endpoints.mcp_management_endpoints._temporary_mcp_servers",
cache,
):
result = get_cached_temporary_mcp_server("expired")
result = await get_cached_temporary_mcp_server("expired")
assert result is None
assert "expired" not in cache
def test_get_cached_temporary_mcp_server_or_404(self):
@pytest.mark.asyncio
async def test_get_cached_temporary_mcp_server_or_404(self):
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
_get_cached_temporary_mcp_server_or_404,
)
@@ -1343,17 +1346,17 @@ class TestTemporaryMCPSessionEndpoints:
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_cached_temporary_mcp_server",
return_value=server,
) as get_cached:
result = _get_cached_temporary_mcp_server_or_404("cached")
result = await _get_cached_temporary_mcp_server_or_404("cached")
assert result is server
get_cached.assert_called_once_with("cached")
get_cached.assert_awaited_once_with("cached")
with patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_cached_temporary_mcp_server",
return_value=None,
):
with pytest.raises(HTTPException) as exc_info:
_get_cached_temporary_mcp_server_or_404("missing")
await _get_cached_temporary_mcp_server_or_404("missing")
assert exc_info.value.status_code == 404
@@ -1403,6 +1406,10 @@ class TestTemporaryMCPSessionEndpoints:
"litellm.proxy.management_endpoints.mcp_management_endpoints._cache_temporary_mcp_server",
MagicMock(),
) as cache_mock,
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints._cache_temporary_mcp_server_in_redis",
AsyncMock(),
) as redis_cache_mock,
):
response = await add_session_mcp_server(
payload=payload,
@@ -1414,6 +1421,9 @@ class TestTemporaryMCPSessionEndpoints:
cache_mock.assert_called_once_with(
built_server, ttl_seconds=TEMPORARY_MCP_SERVER_TTL_SECONDS
)
redis_cache_mock.assert_awaited_once_with(
built_server, ttl_seconds=TEMPORARY_MCP_SERVER_TTL_SECONDS
)
args, _ = mock_manager.build_mcp_server_from_table.call_args
temp_record = args[0]
@@ -1486,7 +1496,7 @@ class TestTemporaryMCPSessionEndpoints:
)
assert result is authorize_response
get_server.assert_called_once_with("server-1", request=request)
get_server.assert_awaited_once_with("server-1", request=request)
authorize_mock.assert_awaited_once_with(
request=request,
mcp_server=server,
@@ -1533,7 +1543,7 @@ class TestTemporaryMCPSessionEndpoints:
)
assert result is exchange_response
get_server.assert_called_once_with("server-1", request=request)
get_server.assert_awaited_once_with("server-1", request=request)
exchange_mock.assert_awaited_once_with(
request=request,
mcp_server=server,
@@ -1581,7 +1591,7 @@ class TestTemporaryMCPSessionEndpoints:
)
assert result is exchange_response
get_server.assert_called_once_with("server-1", request=request)
get_server.assert_awaited_once_with("server-1", request=request)
exchange_mock.assert_awaited_once_with(
request=request,
mcp_server=server,
@@ -1628,7 +1638,7 @@ class TestTemporaryMCPSessionEndpoints:
result = await mcp_register(request=request, server_id="server-1")
assert result is register_response
get_server.assert_called_once_with("server-1", request=request)
get_server.assert_awaited_once_with("server-1", request=request)
read_body.assert_awaited_once_with(request=request)
register_mock.assert_awaited_once_with(
request=request,
@@ -1640,6 +1650,218 @@ class TestTemporaryMCPSessionEndpoints:
fallback_client_id="server-1",
)
@pytest.mark.asyncio
async def test_get_cached_temporary_mcp_server_falls_back_to_redis(self):
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
get_cached_temporary_mcp_server,
)
server = generate_mock_mcp_server_config_record(server_id="from-redis")
serialized = json.dumps(server.model_dump(mode="json"))
mock_cache_backend = SimpleNamespace(
async_get_cache=AsyncMock(return_value="encrypted-payload")
)
original_cache = mgmt_endpoints.litellm.cache
mgmt_endpoints.litellm.cache = SimpleNamespace(cache=mock_cache_backend)
try:
with patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints._temporary_mcp_servers",
{},
), patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.decrypt_value_helper",
return_value=serialized,
):
result = await get_cached_temporary_mcp_server("from-redis")
finally:
mgmt_endpoints.litellm.cache = original_cache
assert result is not None
assert result.server_id == "from-redis"
mock_cache_backend.async_get_cache.assert_awaited_once_with(
key="litellm:mcp:temporary_server:from-redis"
)
@pytest.mark.asyncio
async def test_cache_temporary_mcp_server_in_redis_uses_ttl_and_key(self):
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
_cache_temporary_mcp_server_in_redis,
)
server = generate_mock_mcp_server_config_record(server_id="to-redis")
mock_cache_backend = SimpleNamespace(async_set_cache=AsyncMock())
original_cache = mgmt_endpoints.litellm.cache
mgmt_endpoints.litellm.cache = SimpleNamespace(cache=mock_cache_backend)
try:
with patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.encrypt_value_helper",
return_value="encrypted-payload",
):
await _cache_temporary_mcp_server_in_redis(server, ttl_seconds=123)
finally:
mgmt_endpoints.litellm.cache = original_cache
mock_cache_backend.async_set_cache.assert_awaited_once()
call_kwargs = mock_cache_backend.async_set_cache.await_args.kwargs
assert call_kwargs["key"] == "litellm:mcp:temporary_server:to-redis"
assert call_kwargs["ttl"] == 123
@pytest.mark.asyncio
async def test_cache_temporary_mcp_server_in_redis_encrypts_payload(self):
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
_cache_temporary_mcp_server_in_redis,
)
server = generate_mock_mcp_server_config_record(server_id="to-redis-encrypted")
mock_cache_backend = SimpleNamespace(async_set_cache=AsyncMock())
original_cache = mgmt_endpoints.litellm.cache
mgmt_endpoints.litellm.cache = SimpleNamespace(cache=mock_cache_backend)
try:
with patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.encrypt_value_helper",
return_value="encrypted-payload",
) as encrypt_mock:
await _cache_temporary_mcp_server_in_redis(server, ttl_seconds=60)
finally:
mgmt_endpoints.litellm.cache = original_cache
encrypt_mock.assert_called_once()
call_kwargs = mock_cache_backend.async_set_cache.await_args.kwargs
assert call_kwargs["value"] == "encrypted-payload"
@pytest.mark.asyncio
async def test_get_temporary_mcp_server_from_redis_decrypts_payload(self):
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
_get_temporary_mcp_server_from_redis,
)
server = generate_mock_mcp_server_config_record(server_id="from-redis-encrypted")
serialized = json.dumps(server.model_dump(mode="json"))
mock_cache_backend = SimpleNamespace(
async_get_cache=AsyncMock(return_value="encrypted-payload")
)
original_cache = mgmt_endpoints.litellm.cache
mgmt_endpoints.litellm.cache = SimpleNamespace(cache=mock_cache_backend)
try:
with patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.decrypt_value_helper",
return_value=serialized,
) as decrypt_mock:
result = await _get_temporary_mcp_server_from_redis(
"from-redis-encrypted"
)
finally:
mgmt_endpoints.litellm.cache = original_cache
assert result is not None
assert result.server_id == "from-redis-encrypted"
decrypt_mock.assert_called_once()
@pytest.mark.asyncio
async def test_cache_temporary_mcp_server_in_redis_skips_on_encrypt_failure(self):
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
_cache_temporary_mcp_server_in_redis,
)
server = generate_mock_mcp_server_config_record(server_id="encrypt-fail")
mock_cache_backend = SimpleNamespace(async_set_cache=AsyncMock())
original_cache = mgmt_endpoints.litellm.cache
mgmt_endpoints.litellm.cache = SimpleNamespace(cache=mock_cache_backend)
try:
with patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.encrypt_value_helper",
side_effect=Exception("boom"),
):
await _cache_temporary_mcp_server_in_redis(server, ttl_seconds=60)
finally:
mgmt_endpoints.litellm.cache = original_cache
mock_cache_backend.async_set_cache.assert_not_awaited()
@pytest.mark.asyncio
async def test_cache_temporary_mcp_server_in_redis_skips_non_string_encryption_result(
self,
):
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
_cache_temporary_mcp_server_in_redis,
)
server = generate_mock_mcp_server_config_record(server_id="encrypt-non-string")
mock_cache_backend = SimpleNamespace(async_set_cache=AsyncMock())
original_cache = mgmt_endpoints.litellm.cache
mgmt_endpoints.litellm.cache = SimpleNamespace(cache=mock_cache_backend)
try:
with patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.encrypt_value_helper",
return_value={"not": "a-string"},
):
await _cache_temporary_mcp_server_in_redis(server, ttl_seconds=60)
finally:
mgmt_endpoints.litellm.cache = original_cache
mock_cache_backend.async_set_cache.assert_not_awaited()
@pytest.mark.asyncio
async def test_get_temporary_mcp_server_from_redis_returns_none_on_invalid_decrypt_json(
self,
):
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
_get_temporary_mcp_server_from_redis,
)
mock_cache_backend = SimpleNamespace(async_get_cache=AsyncMock(return_value="enc"))
original_cache = mgmt_endpoints.litellm.cache
mgmt_endpoints.litellm.cache = SimpleNamespace(cache=mock_cache_backend)
try:
with patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.decrypt_value_helper",
return_value="{not json}",
):
result = await _get_temporary_mcp_server_from_redis("bad-json")
finally:
mgmt_endpoints.litellm.cache = original_cache
assert result is None
@pytest.mark.asyncio
async def test_get_temporary_mcp_server_from_redis_returns_none_on_decrypt_none(self):
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
_get_temporary_mcp_server_from_redis,
)
mock_cache_backend = SimpleNamespace(async_get_cache=AsyncMock(return_value="enc"))
original_cache = mgmt_endpoints.litellm.cache
mgmt_endpoints.litellm.cache = SimpleNamespace(cache=mock_cache_backend)
try:
with patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.decrypt_value_helper",
return_value=None,
):
result = await _get_temporary_mcp_server_from_redis("decrypt-none")
finally:
mgmt_endpoints.litellm.cache = original_cache
assert result is None
@pytest.mark.asyncio
async def test_get_temporary_mcp_server_from_redis_rejects_plain_dict_payload(self):
"""Plain dict values in Redis are not accepted (write path is encrypted-only)."""
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
_get_temporary_mcp_server_from_redis,
)
server = generate_mock_mcp_server_config_record(server_id="legacy-dict")
mock_cache_backend = SimpleNamespace(
async_get_cache=AsyncMock(return_value=server.model_dump(mode="json"))
)
original_cache = mgmt_endpoints.litellm.cache
mgmt_endpoints.litellm.cache = SimpleNamespace(cache=mock_cache_backend)
try:
result = await _get_temporary_mcp_server_from_redis("legacy-dict")
finally:
mgmt_endpoints.litellm.cache = original_cache
assert result is None
class TestUpdateMCPServer:
"""Test suite for update MCP server functionality"""
@@ -1795,6 +1795,7 @@ async def test_backfill_team_member_budget_entries_creates_missing_memberships()
return_value=[existing_membership]
)
mock_prisma.db.litellm_teammembership.create_many = AsyncMock(return_value=None)
mock_prisma.db.litellm_teammembership.update_many = AsyncMock(return_value=0)
# Test with Member instances
members = [
@@ -1823,6 +1824,7 @@ async def test_backfill_team_member_budget_entries_creates_missing_memberships()
# Also test with raw dicts (members_with_roles may be dicts when deserialized from DB)
mock_prisma.db.litellm_teammembership.find_many.reset_mock()
mock_prisma.db.litellm_teammembership.create_many.reset_mock()
mock_prisma.db.litellm_teammembership.update_many.reset_mock()
members_as_dicts = [
{"user_id": "user-A", "role": "user"},
@@ -1868,6 +1870,7 @@ async def test_backfill_team_member_budget_entries_no_op_when_all_exist():
return_value=[existing_a, existing_b]
)
mock_prisma.db.litellm_teammembership.create_many = AsyncMock(return_value=None)
mock_prisma.db.litellm_teammembership.update_many = AsyncMock(return_value=0)
members = [
Member(user_id="user-A", role="user"),
@@ -1884,6 +1887,55 @@ async def test_backfill_team_member_budget_entries_no_op_when_all_exist():
mock_prisma.db.litellm_teammembership.create_many.assert_not_awaited()
@pytest.mark.asyncio
async def test_backfill_team_member_budget_entries_populates_null_budget_id_on_existing_rows():
"""
backfill_team_member_budget_entries should populate budget_id on
existing TeamMembership rows where it is currently NULL, so admins
can configure a team member budget after members have already joined
and have enforcement apply to those pre-existing members.
"""
from unittest.mock import AsyncMock, MagicMock
from litellm.proxy._types import Member
from litellm.proxy.management_endpoints.team_endpoints import (
TeamMemberBudgetHandler,
)
team_id = "team-abc"
budget_id = "budget-xyz"
# Both members already have rows, so create_many must not fire;
# update_many must fire with the NULL-budget_id filter.
existing_a = MagicMock()
existing_a.user_id = "user-A"
existing_b = MagicMock()
existing_b.user_id = "user-B"
mock_prisma = MagicMock()
mock_prisma.db.litellm_teammembership.find_many = AsyncMock(
return_value=[existing_a, existing_b]
)
mock_prisma.db.litellm_teammembership.create_many = AsyncMock(return_value=None)
mock_prisma.db.litellm_teammembership.update_many = AsyncMock(return_value=2)
await TeamMemberBudgetHandler.backfill_team_member_budget_entries(
team_id=team_id,
members_with_roles=[
Member(user_id="user-A", role="user"),
Member(user_id="user-B", role="user"),
],
team_member_budget_id=budget_id,
prisma_client=mock_prisma,
)
mock_prisma.db.litellm_teammembership.create_many.assert_not_awaited()
mock_prisma.db.litellm_teammembership.update_many.assert_awaited_once_with(
where={"team_id": team_id, "budget_id": None},
data={"budget_id": budget_id},
)
@pytest.mark.asyncio
async def test_backfill_team_member_budget_entries_empty_members():
"""
@@ -21,6 +21,7 @@ from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import (
bedrock_llm_proxy_route,
create_pass_through_route,
cursor_proxy_route,
get_vertex_base_url,
llm_passthrough_factory_proxy_route,
milvus_proxy_route,
openai_proxy_route,
@@ -31,6 +32,35 @@ from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import (
from litellm.types.passthrough_endpoints.vertex_ai import VertexPassThroughCredentials
class TestVertexPassthroughGetVertexBaseUrl:
"""Module-local get_vertex_base_url (trailing slash); rules match common_utils."""
@pytest.mark.parametrize(
"vertex_location, expected",
[
("global", "https://aiplatform.googleapis.com/"),
("us-central1", "https://us-central1-aiplatform.googleapis.com/"),
("us", "https://aiplatform.us.rep.googleapis.com/"),
("eu", "https://aiplatform.eu.rep.googleapis.com/"),
],
)
def test_returns_base_with_trailing_slash(self, vertex_location, expected):
assert get_vertex_base_url(vertex_location) == expected
@pytest.mark.parametrize(
"vertex_location, expected_host",
[
("global", "aiplatform.googleapis.com"),
("us-central1", "us-central1-aiplatform.googleapis.com"),
("us", "aiplatform.us.rep.googleapis.com"),
("eu", "aiplatform.eu.rep.googleapis.com"),
],
)
def test_websocket_host_strips_scheme(self, vertex_location, expected_host):
host = get_vertex_base_url(vertex_location).removeprefix("https://").rstrip("/")
assert host == expected_host
class TestBaseOpenAIPassThroughHandler:
def test_join_url_paths(self):
print("\nTesting _join_url_paths method...")
@@ -422,6 +422,26 @@ def reset_router_callbacks():
litellm.logging_callback_manager._reset_all_callbacks()
@pytest.fixture(autouse=True)
def reset_proxy_auth_globals(monkeypatch):
"""
Pin proxy auth-related globals to a known baseline so tests don't inherit
leaked state (master_key, prisma_client, custom auth, cached tokens) from
earlier tests. Individual tests can still override via their own
monkeypatch calls those run after this fixture and revert first.
"""
import litellm.proxy.proxy_server as ps
monkeypatch.setattr(ps, "prisma_client", None)
monkeypatch.setattr(ps, "master_key", None)
monkeypatch.setattr(ps, "user_custom_auth", None)
monkeypatch.setattr(ps, "general_settings", {})
try:
ps.user_api_key_cache.in_memory_cache.cache_dict.clear()
except AttributeError:
pass
@pytest.mark.asyncio
async def test_ui_view_spend_logs_with_user_id(client, monkeypatch):
mock_spend_logs = [
@@ -1150,14 +1170,14 @@ async def test_ui_view_spend_logs_date_range_filter(client, monkeypatch):
async def test_ui_view_spend_logs_unauthorized(client):
# Test without authorization header
response = client.get("/spend/logs/ui")
assert response.status_code == 401 or response.status_code == 403
assert response.status_code in (401, 403), response.text
# Test with invalid authorization
response = client.get(
"/spend/logs/ui",
headers={"Authorization": "Bearer invalid-token"},
)
assert response.status_code == 401 or response.status_code == 403
assert response.status_code in (401, 403), response.text
@pytest.mark.asyncio
@@ -4965,3 +4965,123 @@ async def test_increment_spend_counters_team_and_member():
finally:
ps.user_api_key_cache = original_key_cache
ps.spend_counter_cache = original_counter_cache
@pytest.mark.asyncio
async def test_init_and_increment_spend_counter_reseeds_from_db_on_counter_miss():
"""When the Redis counter is missing, the reseed path reads the
authoritative spend from the DB (not a stale cache), so the next
increment continues from the correct base value."""
from litellm.caching.dual_cache import DualCache
counter_cache = DualCache()
recorded_increments: list = []
async def record_increment(key, value, ttl=None, **kwargs):
recorded_increments.append({"key": key, "value": value, "ttl": ttl})
return value
fake_redis = AsyncMock()
fake_redis.async_increment = AsyncMock(side_effect=record_increment)
fake_redis.async_get_cache = AsyncMock(return_value=None) # counter missing
counter_cache.redis_cache = fake_redis
# Prisma returns spend=42.0 (authoritative) while the stale cached
# value (would be read only if prisma is None) is 10.0. The counter
# must seed from 42, not 10.
db_row = MagicMock()
db_row.spend = 42.0
fake_prisma = MagicMock()
fake_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=db_row)
stale_cache = DualCache()
stale_team = MagicMock()
stale_team.spend = 10.0
stale_cache.in_memory_cache.set_cache(key="team_id:team-9", value=stale_team)
import litellm.proxy.proxy_server as ps
from litellm.proxy.proxy_server import _init_and_increment_spend_counter
orig_user, orig_counter, orig_prisma = (
ps.user_api_key_cache,
ps.spend_counter_cache,
ps.prisma_client,
)
ps.user_api_key_cache = stale_cache
ps.spend_counter_cache = counter_cache
ps.prisma_client = fake_prisma
try:
await _init_and_increment_spend_counter(
counter_key="spend:team:team-9",
source_cache_key="team_id:team-9",
increment=1.5,
)
fake_prisma.db.litellm_teamtable.find_unique.assert_awaited_once_with(
where={"team_id": "team-9"}
)
# Two increments keyed on the counter: seed ($42) then request ($1.50).
writes = [(c["key"], c["value"]) for c in recorded_increments]
assert ("spend:team:team-9", 42.0) in writes
assert ("spend:team:team-9", 1.5) in writes
finally:
ps.user_api_key_cache = orig_user
ps.spend_counter_cache = orig_counter
ps.prisma_client = orig_prisma
@pytest.mark.asyncio
async def test_reseed_spend_from_db_user_and_org_prefixes():
"""User and org counters must reseed from their own DB tables, not
fall through to 0.0 like the other counters do today."""
import litellm.proxy.proxy_server as ps
from litellm.proxy.proxy_server import _reseed_spend_from_db
user_row = MagicMock()
user_row.spend = 17.0
org_row = MagicMock()
org_row.spend = 305.0
fake_prisma = MagicMock()
fake_prisma.db.litellm_usertable.find_unique = AsyncMock(return_value=user_row)
fake_prisma.db.litellm_organizationtable.find_unique = AsyncMock(
return_value=org_row
)
orig_prisma = ps.prisma_client
ps.prisma_client = fake_prisma
try:
assert await _reseed_spend_from_db("spend:user:alice") == 17.0
fake_prisma.db.litellm_usertable.find_unique.assert_awaited_once_with(
where={"user_id": "alice"}
)
assert await _reseed_spend_from_db("spend:org:acme") == 305.0
fake_prisma.db.litellm_organizationtable.find_unique.assert_awaited_once_with(
where={"organization_id": "acme"}
)
finally:
ps.prisma_client = orig_prisma
@pytest.mark.asyncio
async def test_reseed_spend_from_db_skips_window_variant_keys():
"""Window counters (spend:*:window:{duration}) share prefixes with
primary counters but don't correspond to a DB row. The guard must
short-circuit without querying the DB."""
import litellm.proxy.proxy_server as ps
from litellm.proxy.proxy_server import _reseed_spend_from_db
fake_prisma = MagicMock()
fake_prisma.db.litellm_verificationtoken.find_unique = AsyncMock()
fake_prisma.db.litellm_teamtable.find_unique = AsyncMock()
orig_prisma = ps.prisma_client
ps.prisma_client = fake_prisma
try:
assert await _reseed_spend_from_db("spend:key:sk-abc:window:1h") == 0.0
assert await _reseed_spend_from_db("spend:team:team-1:window:1d") == 0.0
fake_prisma.db.litellm_verificationtoken.find_unique.assert_not_awaited()
fake_prisma.db.litellm_teamtable.find_unique.assert_not_awaited()
finally:
ps.prisma_client = orig_prisma
@@ -0,0 +1,401 @@
"""
Unit tests for DashScope image generation support (qwen-image-2.0, qwen-image-2.0-pro).
Run in docker: pytest tests/test_litellm/test_dashscope_image_generation.py -v
"""
import json
from unittest.mock import MagicMock, patch
import httpx
import pytest
import litellm
from litellm.llms.dashscope.image_generation.transformation import (
DashScopeImageGenerationConfig,
DEFAULT_API_BASE,
)
from litellm.types.utils import ImageObject, ImageResponse
from litellm.utils import get_llm_provider
# ---------------------------------------------------------------------------
# 1. Provider detection
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"model_string",
[
"dashscope/qwen-image-2.0",
"dashscope/qwen-image-2.0-pro",
],
)
def test_get_llm_provider_returns_dashscope(model_string: str):
model, provider, _, _ = get_llm_provider(model_string)
assert provider == "dashscope", f"Expected 'dashscope', got '{provider}'"
assert "qwen-image" in model
# ---------------------------------------------------------------------------
# 2. Model info: mode == "image_generation"
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"model_string, custom_provider",
[
("dashscope/qwen-image-2.0", "dashscope"),
("dashscope/qwen-image-2.0-pro", "dashscope"),
],
)
def test_get_model_info_mode_is_image_generation(
model_string: str, custom_provider: str
):
import os
prev_env = os.environ.get("LITELLM_LOCAL_MODEL_COST_MAP")
prev_model_cost = litellm.model_cost
try:
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
litellm.model_cost = litellm.get_model_cost_map(url="")
info = litellm.get_model_info(
model=model_string, custom_llm_provider=custom_provider
)
assert (
info["mode"] == "image_generation"
), f"Expected mode='image_generation', got '{info['mode']}'"
finally:
if prev_env is None:
os.environ.pop("LITELLM_LOCAL_MODEL_COST_MAP", None)
else:
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = prev_env
litellm.model_cost = prev_model_cost
# ---------------------------------------------------------------------------
# 3. Request transformation
# ---------------------------------------------------------------------------
class TestDashScopeImageGenerationConfig:
def setup_method(self):
self.cfg = DashScopeImageGenerationConfig()
def test_get_complete_url_default(self):
url = self.cfg.get_complete_url(None, None, "qwen-image-2.0", {}, {})
assert url == DEFAULT_API_BASE
def test_get_complete_url_custom(self):
custom = "https://custom.endpoint/generate"
url = self.cfg.get_complete_url(custom, None, "qwen-image-2.0", {}, {})
assert url == custom
def test_validate_environment_sets_auth_header(self):
headers = self.cfg.validate_environment(
headers={},
model="qwen-image-2.0",
messages=[],
optional_params={},
litellm_params={},
api_key="sk-test-key",
)
assert headers["Authorization"] == "Bearer sk-test-key"
assert headers["Content-Type"] == "application/json"
def test_validate_environment_raises_without_key(self):
with patch(
"litellm.llms.dashscope.image_generation.transformation.get_secret_str",
return_value=None,
):
with pytest.raises(ValueError, match="DASHSCOPE_API_KEY"):
self.cfg.validate_environment(
headers={},
model="qwen-image-2.0",
messages=[],
optional_params={},
litellm_params={},
api_key=None,
)
def test_transform_request_structure(self):
req = self.cfg.transform_image_generation_request(
model="qwen-image-2.0",
prompt="a puppy on green grass",
optional_params={"size": "1024*1024"},
litellm_params={},
headers={},
)
assert req["model"] == "qwen-image-2.0"
messages = req["input"]["messages"]
assert len(messages) == 1
assert messages[0]["role"] == "user"
assert messages[0]["content"][0]["text"] == "a puppy on green grass"
assert req["parameters"]["size"] == "1024*1024"
def test_transform_request_empty_params(self):
req = self.cfg.transform_image_generation_request(
model="qwen-image-2.0-pro",
prompt="sunset over the ocean",
optional_params={},
litellm_params={},
headers={},
)
assert req["parameters"] == {}
# ---------------------------------------------------------------------------
# 4. Response transformation
# ---------------------------------------------------------------------------
def _make_mock_response(self, image_url: str) -> httpx.Response:
body = {
"status_code": 200,
"request_id": "test-request-id",
"output": {
"choices": [
{
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": [{"image": image_url}],
},
}
]
},
"usage": {
"input_tokens": 0,
"output_tokens": 0,
"width": 1024,
"height": 1024,
"image_count": 1,
},
}
mock_resp = MagicMock(spec=httpx.Response)
mock_resp.status_code = 200
mock_resp.headers = {}
mock_resp.json.return_value = body
return mock_resp
def test_transform_response_extracts_url(self):
image_url = "https://example.oss.aliyuncs.com/generated/test.png"
mock_resp = self._make_mock_response(image_url)
model_response = ImageResponse()
result = self.cfg.transform_image_generation_response(
model="qwen-image-2.0",
raw_response=mock_resp,
model_response=model_response,
logging_obj=MagicMock(),
request_data={},
optional_params={},
litellm_params={},
encoding=None,
)
assert result.data is not None
assert len(result.data) == 1
assert result.data[0].url == image_url
def test_transform_response_multiple_images(self):
body = {
"output": {
"choices": [
{
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": [{"image": "https://example.com/img1.png"}],
},
},
{
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": [{"image": "https://example.com/img2.png"}],
},
},
]
},
"usage": {},
}
mock_resp = MagicMock(spec=httpx.Response)
mock_resp.status_code = 200
mock_resp.headers = {}
mock_resp.json.return_value = body
model_response = ImageResponse()
result = self.cfg.transform_image_generation_response(
model="qwen-image-2.0",
raw_response=mock_resp,
model_response=model_response,
logging_obj=MagicMock(),
request_data={},
optional_params={},
litellm_params={},
encoding=None,
)
assert len(result.data) == 2
assert result.data[0].url == "https://example.com/img1.png"
assert result.data[1].url == "https://example.com/img2.png"
def test_transform_response_raises_on_non_200_status(self):
mock_resp = MagicMock(spec=httpx.Response)
mock_resp.status_code = 400
mock_resp.headers = {}
mock_resp.text = '{"code":"InvalidParameter","message":"Size not supported"}'
mock_resp.json.return_value = {
"code": "InvalidParameter",
"message": "Size not supported",
}
with pytest.raises(Exception):
self.cfg.transform_image_generation_response(
model="qwen-image-2.0",
raw_response=mock_resp,
model_response=ImageResponse(),
logging_obj=MagicMock(),
request_data={},
optional_params={},
litellm_params={},
encoding=None,
)
def test_transform_response_raises_on_api_error_body(self):
mock_resp = MagicMock(spec=httpx.Response)
mock_resp.status_code = 200
mock_resp.headers = {}
mock_resp.json.return_value = {
"code": "InvalidParameter",
"message": "Size not supported",
}
with pytest.raises(Exception):
self.cfg.transform_image_generation_response(
model="qwen-image-2.0",
raw_response=mock_resp,
model_response=ImageResponse(),
logging_obj=MagicMock(),
request_data={},
optional_params={},
litellm_params={},
encoding=None,
)
# ---------------------------------------------------------------------------
# 5. OpenAI → DashScope parameter mapping
# ---------------------------------------------------------------------------
def test_map_openai_params_size_conversion(self):
mapped = self.cfg.map_openai_params(
non_default_params={"size": "1024x1024"},
optional_params={},
model="qwen-image-2.0",
drop_params=False,
)
assert mapped["size"] == "1024*1024"
def test_map_openai_params_n_to_image_count(self):
mapped = self.cfg.map_openai_params(
non_default_params={"n": 2},
optional_params={},
model="qwen-image-2.0",
drop_params=False,
)
assert mapped["image_count"] == 2
def test_map_openai_params_unknown_size_uses_asterisk(self):
mapped = self.cfg.map_openai_params(
non_default_params={"size": "768x768"},
optional_params={},
model="qwen-image-2.0",
drop_params=False,
)
assert mapped["size"] == "768*768"
@pytest.mark.parametrize(
"openai_size, expected",
[
("256x256", "256*256"),
("512x512", "512*512"),
("1024x1024", "1024*1024"),
("1792x1024", "1792*1024"),
("1024x1792", "1024*1792"),
("2048x2048", "2048*2048"),
],
)
def test_map_openai_params_size_table(self, openai_size: str, expected: str):
mapped = self.cfg.map_openai_params(
non_default_params={"size": openai_size},
optional_params={},
model="qwen-image-2.0",
drop_params=False,
)
assert mapped["size"] == expected
# ---------------------------------------------------------------------------
# 6. End-to-end flow via litellm.image_generation (HTTP mocked)
# ---------------------------------------------------------------------------
def test_litellm_image_generation_dashscope_end_to_end():
mock_response_body = {
"output": {
"choices": [
{
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": [
{
"image": "https://dashscope-result.oss.aliyuncs.com/test.png"
}
],
},
}
]
},
"usage": {
"input_tokens": 0,
"output_tokens": 0,
"width": 1024,
"height": 1024,
"image_count": 1,
},
}
with patch(
"litellm.llms.custom_httpx.llm_http_handler.HTTPHandler.post"
) as mock_post:
mock_http_response = MagicMock()
mock_http_response.json.return_value = mock_response_body
mock_http_response.status_code = 200
mock_http_response.headers = {}
mock_post.return_value = mock_http_response
response = litellm.image_generation(
model="dashscope/qwen-image-2.0",
prompt="a puppy playing on green grass",
api_key="sk-test-key",
size="1024x1024",
)
assert response is not None
assert response.data is not None
assert len(response.data) == 1
assert (
response.data[0].url == "https://dashscope-result.oss.aliyuncs.com/test.png"
)
# Verify the HTTP call was made to the DashScope endpoint
call_args = mock_post.call_args
called_url = (
call_args[0][0] if call_args[0] else call_args.kwargs.get("url", "")
)
assert "dashscope" in called_url or "aliyuncs" in called_url
# Verify request body contains DashScope format
call_kwargs = call_args[1] if call_args[1] else {}
if "json" in call_kwargs:
body = call_kwargs["json"]
assert "input" in body
assert "messages" in body["input"]
@@ -243,6 +243,7 @@ export default function SpendLogsTable({
allTeams,
handleFilterChange,
handleFilterReset: handleFilterResetFromHook,
refetchWithFilters,
} = useLogFilterLogic({
logs: logsData,
accessToken,
@@ -363,7 +364,14 @@ export default function SpendLogsTable({
// Add this function to handle manual refresh
const handleRefresh = () => {
logs.refetch();
if (hasBackendFilters) {
// When backend filters (e.g. Key Alias) are active the main TanStack Query
// is disabled and its params do not include filter values like key_alias.
// Route through the filter-aware refetch so all active filters are preserved.
refetchWithFilters();
} else {
logs.refetch();
}
};
const handleRowClick = (log: LogEntry) => {
@@ -71,6 +71,14 @@ export function useLogFilterLogic({
const [filters, setFilters] = useState<LogFilterState>(defaultFilters);
const [backendFilteredLogs, setBackendFilteredLogs] = useState<PaginatedResponse | null>(null);
const lastSearchTimestamp = useRef(0);
// Refs that always hold the latest filters and hasBackendFilters values.
// The sort/page/time effect below intentionally omits these from its dep array
// to avoid double-fetches when a filter changes; reading from refs instead of
// the closure prevents stale-closure bugs (e.g. the effect using a snapshot of
// filters taken before the user selected Key Alias).
const filtersRef = useRef(filters);
const hasBackendFiltersRef = useRef(false);
const performSearch = useCallback(
async (filters: LogFilterState, page = 1) => {
if (!accessToken) return;
@@ -152,18 +160,25 @@ export function useLogFilterLogic({
[filters],
);
// Keep refs in sync on every render so the sort/page/time effect always reads
// the latest values without those values being in its dep array.
useEffect(() => {
filtersRef.current = filters;
hasBackendFiltersRef.current = hasBackendFilters;
}, [filters, hasBackendFilters]);
// Refetch when sort, page, or time range changes (backend filters use their own fetch, not the main query)
useEffect(() => {
if (hasBackendFilters && accessToken) {
if (hasBackendFiltersRef.current && accessToken) {
// Cancel any pending debounced search to prevent it from overwriting this page's results
debouncedSearch.cancel();
performSearch(filters, currentPage);
performSearch(filtersRef.current, currentPage);
}
// Intentionally omitted from deps:
// - `filters` / `debouncedSearch` / `performSearch`: filter changes are handled by
// handleFilterChange → debouncedSearch; adding them here would double-fetch on filter apply.
// - `hasBackendFilters` / `accessToken`: stable across sort/page/time changes; including them
// would cause spurious re-runs when the filter state first becomes active.
// filters / hasBackendFilters are read via refs — avoids stale-closure bugs
// when sort/page/time changes after a filter (e.g. Key Alias) was set.
// debouncedSearch / performSearch: filter changes go through handleFilterChange
// → debouncedSearch; adding them here would cause double-fetches on filter apply.
// accessToken: stable across sort/page/time changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sortBy, sortOrder, currentPage, startTime, endTime, isCustomDate]);
@@ -299,6 +314,20 @@ export function useLogFilterLogic({
setCurrentPage(1);
};
// Expose a filter-aware refetch so callers (e.g. the manual Fetch button) can
// refresh results while keeping all active backend filters intact. The plain
// `logs.refetch()` in the parent only re-runs the main TanStack Query, which
// does not carry key_alias or other backend-only filter params.
const refetchWithFilters = useCallback(
(page = currentPage) => {
if (hasBackendFilters && accessToken) {
debouncedSearch.cancel();
performSearch(filters, page);
}
},
[hasBackendFilters, accessToken, filters, currentPage, performSearch, debouncedSearch],
);
return {
filters,
filteredLogs,
@@ -306,5 +335,6 @@ export function useLogFilterLogic({
allTeams,
handleFilterChange,
handleFilterReset,
refetchWithFilters,
};
}
Generated
+3 -3
View File
@@ -9,7 +9,7 @@ resolution-markers = [
]
[options]
exclude-newer = "2026-04-19T01:10:36.69677Z"
exclude-newer = "2026-04-20T01:21:50.985363Z"
exclude-newer-span = "P3D"
[manifest]
@@ -3085,7 +3085,7 @@ wheels = [
[[package]]
name = "litellm"
version = "1.83.11"
version = "1.83.12"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@@ -3418,7 +3418,7 @@ source = { editable = "enterprise" }
[[package]]
name = "litellm-proxy-extras"
version = "0.4.67"
version = "0.4.68"
source = { editable = "litellm-proxy-extras" }
[[package]]