mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-06 16:24:46 +00:00
Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_team_member_total_spend_frontend
This commit is contained in:
+184
-540
File diff suppressed because it is too large
Load Diff
@@ -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}`);
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
:::
|
||||
|
||||
@@ -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==",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+30
-10
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
+3930
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||
|
||||
+113
@@ -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
|
||||
|
||||
+16
-13
@@ -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"
|
||||
|
||||
+173
@@ -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"
|
||||
+287
@@ -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)
|
||||
|
||||
+21
-31
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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]]
|
||||
|
||||
Reference in New Issue
Block a user