diff --git a/.circleci/config.yml b/.circleci/config.yml
index 544a5a1eed..7f410baf8f 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -69,9 +69,11 @@ jobs:
- run:
name: Install Python
command: |
- choco install python --version=3.11.0 -y
+ choco install python --version=3.11.0 -y --no-progress --force
refreshenv
python --version
+ environment:
+ CHOCOLATEY_CONFIRM_ALL: "true"
- run:
name: Install Dependencies
command: |
diff --git a/.github/workflows/publish_enterprise.yml b/.github/workflows/publish_enterprise.yml
index a23eda8819..459a233cb7 100644
--- a/.github/workflows/publish_enterprise.yml
+++ b/.github/workflows/publish_enterprise.yml
@@ -19,6 +19,7 @@ jobs:
if: github.repository == 'BerriAI/litellm'
permissions:
contents: write
+ pull-requests: write
defaults:
run:
working-directory: enterprise
@@ -56,14 +57,33 @@ jobs:
- name: Build
run: poetry build
- - name: Commit version bump
+ - name: Commit version bump and create PR
+ id: create-pr
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
cd ..
+ BRANCH="bump/enterprise-${{ steps.bump.outputs.new }}"
+ git checkout -b "$BRANCH"
git add enterprise/pyproject.toml pyproject.toml requirements.txt poetry.lock
git commit -m "bump: litellm-enterprise ${{ steps.bump.outputs.old }} → ${{ steps.bump.outputs.new }}"
- git push
+ git push origin "$BRANCH" --force
+ gh pr create \
+ --title "bump: litellm-enterprise ${{ steps.bump.outputs.old }} → ${{ steps.bump.outputs.new }}" \
+ --body "Version bump for litellm-enterprise. Merge to update main." \
+ --head "$BRANCH" \
+ --base main \
+ || true
+ PR_URL=$(gh pr list --head "$BRANCH" --json url -q '.[0].url')
+ echo "pr_url=$PR_URL" >> $GITHUB_OUTPUT
+ env:
+ GH_TOKEN: ${{ github.token }}
+
+ - name: Enable auto-merge
+ run: |
+ gh pr merge "${{ steps.create-pr.outputs.pr_url }}" --auto --squash
+ env:
+ GH_TOKEN: ${{ github.token }}
- name: Publish to PyPI
env:
diff --git a/docs/my-website/docs/proxy/config_settings.md b/docs/my-website/docs/proxy/config_settings.md
index 120e044f9c..ea2c1700ee 100644
--- a/docs/my-website/docs/proxy/config_settings.md
+++ b/docs/my-website/docs/proxy/config_settings.md
@@ -355,7 +355,7 @@ router_settings:
| set_verbose | boolean | [DEPRECATED PARAM - see debug docs](./debugging) If true, sets the logging level to verbose. |
| retry_after | int | Time to wait before retrying a request in seconds. Defaults to 0. If `x-retry-after` is received from LLM API, this value is overridden. |
| provider_budget_config | ProviderBudgetConfig | Provider budget configuration. Use this to set llm_provider budget limits. example $100/day to OpenAI, $100/day to Azure, etc. Defaults to None. [Further Docs](./provider_budget_routing.md) |
-| enable_pre_call_checks | boolean | If true, checks if a call is within the model's context window before making the call. [More information here](reliability) |
+| enable_pre_call_checks | boolean | If true, checks if a call is within the model's context window before making the call. **Required** for `model_info.max_input_tokens` enforcement. Default: false. [More information here](reliability) |
| model_group_retry_policy | Dict[str, RetryPolicy] | [SDK-only arg] Set retry policy for model groups. |
| context_window_fallbacks | List[Dict[str, List[str]]] | Fallback models for context window violations. |
| redis_url | str | URL for Redis server. **Known performance issue with Redis URL.** |
@@ -804,6 +804,7 @@ router_settings:
| PYROSCOPE_SERVER_ADDRESS | Pyroscope server URL to send profiles to. Required when LITELLM_ENABLE_PYROSCOPE is true. No default.
| PYROSCOPE_SAMPLE_RATE | Optional. Sample rate for Pyroscope profiling (integer). No default; when unset, the pyroscope-io library default is used.
| LITELLM_MASTER_KEY | Master key for proxy authentication
+| LITELLM_MAX_BUDGET_PER_SESSION_TTL | TTL in seconds for session budget counters used by the max-budget-per-session limiter. Default is 3600 (1 hour)
| LITELLM_MAX_ITERATIONS_TTL | TTL in seconds for session iteration counters used by the max-iterations limiter. Default is 3600 (1 hour)
| LITELLM_MAX_STREAMING_DURATION_SECONDS | Maximum duration in seconds allowed for a streaming response. Streams exceeding this duration are terminated with a Timeout error. Default is None (no limit)
| LITELLM_MODE | Operating mode for LiteLLM (e.g., production, development)
diff --git a/docs/my-website/docs/proxy/reliability.md b/docs/my-website/docs/proxy/reliability.md
index 86de7cc114..d58572cb64 100644
--- a/docs/my-website/docs/proxy/reliability.md
+++ b/docs/my-website/docs/proxy/reliability.md
@@ -713,6 +713,34 @@ curl -X POST 'http://0.0.0.0:4000/chat/completions' \
[**See Code**](https://github.com/BerriAI/litellm/blob/c9e6b05cfb20dfb17272218e2555d6b496c47f6f/litellm/router.py#L2163)
+:::important
+**`enable_pre_call_checks` is required** for context-window enforcement. Without it, requests are sent to the provider regardless of input token count. Set `enable_pre_call_checks: true` in `router_settings` in your config.
+:::
+
+#### Custom max_input_tokens per deployment
+
+You can override the default context limit for a deployment by setting `max_input_tokens` in `model_info`. This is useful for testing, rate-limiting long prompts, or enforcing stricter limits than the provider's default.
+
+**Both** of the following are required:
+
+1. **`router_settings.enable_pre_call_checks: true`** — enables pre-call checks
+2. **`model_info.max_input_tokens`** on the deployment — overrides the limit for that model
+
+```yaml
+router_settings:
+ enable_pre_call_checks: true # Required for enforcement
+
+model_list:
+ - model_name: gpt-4o
+ litellm_params:
+ model: openai/gpt-4o
+ api_key: os.environ/OPENAI_API_KEY
+ model_info:
+ max_input_tokens: 10 # Override: reject prompts > 10 tokens
+```
+
+If a request exceeds the limit, LiteLLM raises `ContextWindowExceededError` with details like `Model=gpt-4o, Max Input Tokens=10, Got=306`.
+
**1. Setup config**
For azure deployments, set the base model. Pick the base model from [this list](https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json), all the azure models start with azure/.
diff --git a/docs/my-website/docs/search/index.md b/docs/my-website/docs/search/index.md
index 37e6e34434..00eb35e528 100644
--- a/docs/my-website/docs/search/index.md
+++ b/docs/my-website/docs/search/index.md
@@ -2,7 +2,7 @@
| Feature | Supported |
|---------|-----------|
-| Supported Providers | `perplexity`, `tavily`, `parallel_ai`, `exa_ai`, `brave`, `google_pse`, `dataforseo`, `firecrawl`, `searxng`, `linkup`, `duckduckgo`, `searchapi` |
+| Supported Providers | `perplexity`, `tavily`, `parallel_ai`, `exa_ai`, `brave`, `google_pse`, `dataforseo`, `firecrawl`, `searxng`, `linkup`, `duckduckgo`, `searchapi`, `serper` |
| Cost Tracking | ✅ |
| Logging | ✅ |
| Load Balancing | ❌ |
@@ -210,7 +210,7 @@ See the [official Perplexity Search documentation](https://docs.perplexity.ai/ap
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `query` | string or array | Yes | Search query. Can be a single string or array of strings |
-| `search_provider` | string | Yes (SDK) | The search provider to use: `"perplexity"`, `"tavily"`, `"parallel_ai"`, `"exa_ai"`, `"brave"`, `"google_pse"`, `"dataforseo"`, `"firecrawl"`, `"searxng"`, `"linkup"`, `"duckduckgo"`, or `"searchapi"` |
+| `search_provider` | string | Yes (SDK) | The search provider to use: `"perplexity"`, `"tavily"`, `"parallel_ai"`, `"exa_ai"`, `"brave"`, `"google_pse"`, `"dataforseo"`, `"firecrawl"`, `"searxng"`, `"linkup"`, `"duckduckgo"`, `"searchapi"`, or `"serper"` |
| `search_tool_name` | string | Yes (Proxy) | Name of the search tool configured in `config.yaml` |
| `max_results` | integer | No | Maximum number of results to return (1-20). Default: 10 |
| `search_domain_filter` | array | No | List of domains to filter results (max 20 domains) |
@@ -276,6 +276,7 @@ The response follows Perplexity's search format with the following structure:
| Firecrawl | `FIRECRAWL_API_KEY` | `firecrawl` |
| SearXNG | `SEARXNG_API_BASE` (required) | `searxng` |
| Linkup | `LINKUP_API_KEY` | `linkup` |
+| Serper | `SERPER_API_KEY` | `serper` |
| DuckDuckGo | `DUCKDUCKGO_API_BASE` | `duckduckgo` |
| SearchAPI.io | `SEARCHAPI_API_KEY` | `searchapi` |
diff --git a/docs/my-website/docs/search/serper.md b/docs/my-website/docs/search/serper.md
new file mode 100644
index 0000000000..30e0409397
--- /dev/null
+++ b/docs/my-website/docs/search/serper.md
@@ -0,0 +1,77 @@
+# Serper Search
+
+**Get API Key:** [https://serper.dev](https://serper.dev)
+
+## LiteLLM Python SDK
+
+```python showLineNumbers title="Serper Search"
+import os
+from litellm import search
+
+os.environ["SERPER_API_KEY"] = "your-api-key"
+
+response = search(
+ query="latest AI developments",
+ search_provider="serper",
+ max_results=5
+)
+```
+
+## LiteLLM AI Gateway
+
+### 1. Setup config.yaml
+
+```yaml showLineNumbers title="config.yaml"
+model_list:
+ - model_name: gpt-5
+ litellm_params:
+ model: gpt-5
+ api_key: os.environ/OPENAI_API_KEY
+
+search_tools:
+ - search_tool_name: serper-search
+ litellm_params:
+ search_provider: serper
+ api_key: os.environ/SERPER_API_KEY
+```
+
+### 2. Start the proxy
+
+```bash
+litellm --config /path/to/config.yaml
+
+# RUNNING on http://0.0.0.0:4000
+```
+
+### 3. Test the search endpoint
+
+```bash showLineNumbers title="Test Request"
+curl http://0.0.0.0:4000/v1/search/serper-search \
+ -H "Authorization: Bearer sk-1234" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "query": "latest AI developments",
+ "max_results": 5
+ }'
+```
+
+## Provider-specific Parameters
+
+```python showLineNumbers title="Serper Search with Provider-specific Parameters"
+import os
+from litellm import search
+
+os.environ["SERPER_API_KEY"] = "your-api-key"
+
+response = search(
+ query="latest tech news",
+ search_provider="serper",
+ max_results=10,
+ # Serper-specific parameters
+ gl="us", # Country/geolocation code
+ hl="en", # Language code
+ autocorrect=False, # Disable autocorrect
+ tbs="qdr:d", # Time filter: past day ('qdr:h' hour, 'qdr:w' week, 'qdr:m' month)
+ page=2 # Page number
+)
+```
diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js
index ef2df2d8ad..b4a1337d54 100644
--- a/docs/my-website/sidebars.js
+++ b/docs/my-website/sidebars.js
@@ -684,6 +684,7 @@ const sidebars = {
"search/firecrawl",
"search/searxng",
"search/linkup",
+ "search/serper",
]
},
"skills",
diff --git a/enterprise/pyproject.toml b/enterprise/pyproject.toml
index e77b8690f8..515885944f 100644
--- a/enterprise/pyproject.toml
+++ b/enterprise/pyproject.toml
@@ -1,6 +1,6 @@
[tool.poetry]
name = "litellm-enterprise"
-version = "0.1.33"
+version = "0.1.34"
description = "Package for LiteLLM Enterprise features"
authors = ["BerriAI"]
readme = "README.md"
diff --git a/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.53-py3-none-any.whl b/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.53-py3-none-any.whl
new file mode 100644
index 0000000000..019b21ccdf
Binary files /dev/null and b/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.53-py3-none-any.whl differ
diff --git a/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.53.tar.gz b/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.53.tar.gz
new file mode 100644
index 0000000000..773a40d38d
Binary files /dev/null and b/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.53.tar.gz differ
diff --git a/litellm-proxy-extras/pyproject.toml b/litellm-proxy-extras/pyproject.toml
index 25533a09f0..ef80f092f1 100644
--- a/litellm-proxy-extras/pyproject.toml
+++ b/litellm-proxy-extras/pyproject.toml
@@ -1,6 +1,6 @@
[tool.poetry]
name = "litellm-proxy-extras"
-version = "0.4.52"
+version = "0.4.53"
description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package."
authors = ["BerriAI"]
readme = "README.md"
@@ -22,7 +22,7 @@ requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.commitizen]
-version = "0.4.52"
+version = "0.4.53"
version_files = [
"pyproject.toml:version",
"../requirements.txt:litellm-proxy-extras==",
diff --git a/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py b/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py
index ae11b57a98..4bc9f0c835 100644
--- a/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py
+++ b/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py
@@ -2,7 +2,7 @@ import asyncio
import json
import time
import traceback
-from typing import Dict, Iterable, List, Literal, Optional, Tuple, Union
+from typing import Dict, Iterable, List, Literal, Optional, Tuple, Union, cast
import litellm
from litellm._logging import verbose_logger
@@ -13,6 +13,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
from litellm.types.llms.databricks import DatabricksTool
from litellm.types.llms.openai import (
ChatCompletionThinkingBlock,
+ ImageURLListItem,
OpenAIModerationResponse,
)
from litellm.types.utils import (
@@ -26,13 +27,13 @@ from litellm.types.utils import (
Function,
HiddenParams,
ImageResponse,
- PromptTokensDetailsWrapper,
)
from litellm.types.utils import Logprobs as TextCompletionLogprobs
from litellm.types.utils import (
Message,
ModelResponse,
ModelResponseStream,
+ PromptTokensDetailsWrapper,
RerankResponse,
StreamingChoices,
TextChoices,
@@ -52,6 +53,24 @@ _MODEL_RESPONSE_FIELDS: frozenset = frozenset(ModelResponse.model_fields.keys())
}
+def _normalize_images_for_message(
+ images: Optional[List[dict]],
+) -> Optional[List[ImageURLListItem]]:
+ """
+ Ensure each image has an 'index' field, as required by ImageURLListItem.
+ Some providers (e.g. OpenRouter) return images without index.
+ """
+ if not images:
+ return cast(Optional[List[ImageURLListItem]], images)
+ normalized: List[ImageURLListItem] = []
+ for i, img in enumerate(images):
+ if isinstance(img, dict) and "index" not in img:
+ normalized.append(cast(ImageURLListItem, {**img, "index": i}))
+ else:
+ normalized.append(cast(ImageURLListItem, img))
+ return normalized
+
+
def _safe_convert_created_field(created_value) -> int:
"""
Safely convert a 'created' field value to an integer.
@@ -591,7 +610,9 @@ def convert_to_model_response_object( # noqa: PLR0915
reasoning_content=reasoning_content,
thinking_blocks=thinking_blocks,
annotations=choice["message"].get("annotations", None),
- images=choice["message"].get("images", None),
+ images=_normalize_images_for_message(
+ choice["message"].get("images", None)
+ ),
)
finish_reason = choice.get("finish_reason", None)
if finish_reason is None:
diff --git a/litellm/litellm_core_utils/redact_messages.py b/litellm/litellm_core_utils/redact_messages.py
index ad68f3851a..41cc200141 100644
--- a/litellm/litellm_core_utils/redact_messages.py
+++ b/litellm/litellm_core_utils/redact_messages.py
@@ -73,6 +73,53 @@ def _redact_responses_api_output(output_items):
summary_item.text = "redacted-by-litellm"
+def _redact_standard_logging_object(model_call_details: dict):
+ """Redact messages and response inside standard_logging_object if present."""
+ standard_logging_object = model_call_details.get("standard_logging_object")
+ if standard_logging_object is None:
+ return
+
+ redacted_str = "redacted-by-litellm"
+
+ if standard_logging_object.get("messages") is not None:
+ standard_logging_object["messages"] = [
+ {"role": "user", "content": redacted_str}
+ ]
+
+ response = standard_logging_object.get("response")
+ if response is not None:
+ if isinstance(response, dict) and "output" in response:
+ # ResponsesAPIResponse format - redact content in output items
+ if isinstance(response.get("output"), list):
+ for output_item in response["output"]:
+ if isinstance(output_item, dict) and "content" in output_item:
+ if isinstance(output_item["content"], list):
+ for content_item in output_item["content"]:
+ if (
+ isinstance(content_item, dict)
+ and "text" in content_item
+ ):
+ content_item["text"] = redacted_str
+ elif isinstance(response, dict) and "choices" in response:
+ # ModelResponse dict format - redact content in choices
+ if isinstance(response.get("choices"), list):
+ for choice in response["choices"]:
+ if isinstance(choice, dict):
+ if "message" in choice and isinstance(choice["message"], dict):
+ choice["message"]["content"] = redacted_str
+ if "audio" in choice["message"]:
+ choice["message"]["audio"] = None
+ elif "delta" in choice and isinstance(choice["delta"], dict):
+ choice["delta"]["content"] = redacted_str
+ if "audio" in choice["delta"]:
+ choice["delta"]["audio"] = None
+ elif isinstance(response, str):
+ standard_logging_object["response"] = redacted_str
+ else:
+ # For other formats (empty dict, None, etc.), use simple text format
+ standard_logging_object["response"] = {"text": redacted_str}
+
+
def perform_redaction(model_call_details: dict, result):
"""
Performs the actual redaction on the logging object and result.
@@ -114,6 +161,29 @@ def perform_redaction(model_call_details: dict, result):
if hasattr(_result, "choices") and _result.choices is not None:
for choice in _result.choices:
_redact_choice_content(choice)
+ elif isinstance(_result, dict) and "choices" in _result:
+ # Handle dict representation of ModelResponse (e.g., from model_dump())
+ if _result.get("choices") is not None:
+ for choice in _result["choices"]:
+ if isinstance(choice, dict):
+ if "message" in choice and isinstance(choice["message"], dict):
+ choice["message"]["content"] = "redacted-by-litellm"
+ if "reasoning_content" in choice["message"]:
+ choice["message"]["reasoning_content"] = "redacted-by-litellm"
+ if "thinking_blocks" in choice["message"]:
+ choice["message"]["thinking_blocks"] = None
+ if "audio" in choice["message"]:
+ choice["message"]["audio"] = None
+ elif "delta" in choice and isinstance(choice["delta"], dict):
+ choice["delta"]["content"] = "redacted-by-litellm"
+ if "reasoning_content" in choice["delta"]:
+ choice["delta"]["reasoning_content"] = "redacted-by-litellm"
+ if "thinking_blocks" in choice["delta"]:
+ choice["delta"]["thinking_blocks"] = None
+ if "audio" in choice["delta"]:
+ choice["delta"]["audio"] = None
+ else:
+ _redact_choice_content(choice)
elif isinstance(_result, litellm.ResponsesAPIResponse):
if hasattr(_result, "output"):
_redact_responses_api_output(_result.output)
diff --git a/litellm/llms/bedrock/chat/agentcore/transformation.py b/litellm/llms/bedrock/chat/agentcore/transformation.py
index fe7d4b194a..560fadad7c 100644
--- a/litellm/llms/bedrock/chat/agentcore/transformation.py
+++ b/litellm/llms/bedrock/chat/agentcore/transformation.py
@@ -334,24 +334,67 @@ class AmazonAgentCoreConfig(BaseConfig, BaseAWSLLM):
"""
Parse direct JSON response (non-streaming).
- JSON response structure:
- {
- "result": {
- "role": "assistant",
- "content": [{"text": "..."}]
- }
- }
+ Supports multiple agent response schemas:
+ 1. {"result": {"role": "assistant", "content": [{"text": "..."}]}} - standard AgentCore
+ 2. {"response": [{"text": "..."}]} - Strands agent format
+ 3. {"result": "plain text"} or {"response": "plain text"} - simple string
+ 4. Fallback: raw JSON as content string
"""
- result = response_json.get("result", {})
+ # Guard: if json.loads() returned a non-dict (e.g. array or primitive),
+ # skip strategy matching and fall back to raw JSON string
+ if not isinstance(response_json, dict):
+ verbose_logger.warning(
+ "AgentCore: JSON response is not a dict. "
+ "Returning raw JSON as content."
+ )
+ return AgentCoreParsedResponse(
+ content=json.dumps(response_json),
+ usage=None,
+ final_message=None,
+ )
- # Extract content using the same helper as SSE parsing
- content = self._extract_content_from_message(result) # type: ignore
+ # Strategy 1: {"result": {"content": [{"text": "..."}]}} - standard AgentCore format
+ if "result" in response_json and isinstance(response_json["result"], dict):
+ result = response_json["result"]
+ content = self._extract_content_from_message(result) # type: ignore
+ return AgentCoreParsedResponse(
+ content=content,
+ usage=None,
+ final_message=result, # type: ignore
+ )
- # JSON responses don't include usage data
+ # Strategy 2: {"response": [{"text": "..."}]} - Strands agent content blocks
+ if "response" in response_json and isinstance(
+ response_json["response"], list
+ ):
+ content = self._extract_content_from_message(
+ {"content": response_json["response"]} # type: ignore
+ )
+ return AgentCoreParsedResponse(
+ content=content,
+ usage=None,
+ final_message=None,
+ )
+
+ # Strategy 3: string values - {"result": "text"} or {"response": "text"}
+ for key in ("result", "response"):
+ val = response_json.get(key)
+ if isinstance(val, str):
+ return AgentCoreParsedResponse(
+ content=val,
+ usage=None,
+ final_message=None,
+ )
+
+ # Strategy 4: fallback - return raw JSON as content
+ verbose_logger.warning(
+ f"AgentCore: Could not extract content from JSON response keys "
+ f"{list(response_json.keys())}. Returning raw JSON as content."
+ )
return AgentCoreParsedResponse(
- content=content,
+ content=json.dumps(response_json),
usage=None,
- final_message=result, # type: ignore
+ final_message=None,
)
def _get_parsed_response(
@@ -589,7 +632,64 @@ class AmazonAgentCoreConfig(BaseConfig, BaseAWSLLM):
additional_args={"complete_input_dict": data},
)
- # Wrap the generator in CustomStreamWrapper
+ # Check if response is JSON (agent used sync return) instead of SSE
+ content_type = response.headers.get("content-type", "").lower()
+ if "application/json" in content_type:
+ verbose_logger.debug(
+ "AgentCore streaming: received JSON response instead of SSE, "
+ "converting to single-chunk stream"
+ )
+ try:
+ body = response.read()
+ response_json = json.loads(body)
+ except (json.JSONDecodeError, Exception) as e:
+ raise BedrockError(
+ status_code=response.status_code,
+ message=f"AgentCore: Failed to read/parse JSON response body: {e}",
+ )
+ parsed = self._parse_json_response(response_json)
+
+ def _json_as_sync_stream():
+ # Content chunk
+ content_chunk = ModelResponseStream(
+ id=f"chatcmpl-{uuid.uuid4()}",
+ created=0,
+ model=model,
+ object="chat.completion.chunk",
+ )
+ content_chunk.choices = [
+ StreamingChoices(
+ finish_reason=None,
+ index=0,
+ delta=Delta(content=parsed["content"], role="assistant"),
+ )
+ ]
+ yield content_chunk
+
+ # Stop sentinel chunk (matches SSE path convention)
+ stop_chunk = ModelResponseStream(
+ id=f"chatcmpl-{uuid.uuid4()}",
+ created=0,
+ model=model,
+ object="chat.completion.chunk",
+ )
+ stop_chunk.choices = [
+ StreamingChoices(
+ finish_reason="stop",
+ index=0,
+ delta=Delta(),
+ )
+ ]
+ yield stop_chunk
+
+ return CustomStreamWrapper(
+ completion_stream=_json_as_sync_stream(),
+ model=model,
+ custom_llm_provider="bedrock",
+ logging_obj=logging_obj,
+ )
+
+ # SSE stream (text/event-stream or default) - use existing SSE parser
return CustomStreamWrapper(
completion_stream=self._stream_agentcore_response_sync(response, model),
model=model,
@@ -746,7 +846,64 @@ class AmazonAgentCoreConfig(BaseConfig, BaseAWSLLM):
additional_args={"complete_input_dict": data},
)
- # Wrap the async generator in CustomStreamWrapper
+ # Check if response is JSON (agent used sync return) instead of SSE
+ content_type = response.headers.get("content-type", "").lower()
+ if "application/json" in content_type:
+ verbose_logger.debug(
+ "AgentCore streaming: received JSON response instead of SSE, "
+ "converting to single-chunk stream"
+ )
+ try:
+ body = await response.aread()
+ response_json = json.loads(body)
+ except (json.JSONDecodeError, Exception) as e:
+ raise BedrockError(
+ status_code=response.status_code,
+ message=f"AgentCore: Failed to read/parse JSON response body: {e}",
+ )
+ parsed = self._parse_json_response(response_json)
+
+ async def _json_as_async_stream() -> AsyncGenerator[ModelResponseStream, None]:
+ # Content chunk
+ content_chunk = ModelResponseStream(
+ id=f"chatcmpl-{uuid.uuid4()}",
+ created=0,
+ model=model,
+ object="chat.completion.chunk",
+ )
+ content_chunk.choices = [
+ StreamingChoices(
+ finish_reason=None,
+ index=0,
+ delta=Delta(content=parsed["content"], role="assistant"),
+ )
+ ]
+ yield content_chunk
+
+ # Stop sentinel chunk (matches SSE path convention)
+ stop_chunk = ModelResponseStream(
+ id=f"chatcmpl-{uuid.uuid4()}",
+ created=0,
+ model=model,
+ object="chat.completion.chunk",
+ )
+ stop_chunk.choices = [
+ StreamingChoices(
+ finish_reason="stop",
+ index=0,
+ delta=Delta(),
+ )
+ ]
+ yield stop_chunk
+
+ return CustomStreamWrapper(
+ completion_stream=_json_as_async_stream(),
+ model=model,
+ custom_llm_provider="bedrock",
+ logging_obj=logging_obj,
+ )
+
+ # SSE stream (text/event-stream or default) - use existing SSE parser
return CustomStreamWrapper(
completion_stream=self._stream_agentcore_response(response, model),
model=model,
diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py
index d210f294c6..4fa407701c 100644
--- a/litellm/llms/bedrock/chat/converse_transformation.py
+++ b/litellm/llms/bedrock/chat/converse_transformation.py
@@ -1206,6 +1206,7 @@ class AmazonConverseConfig(BaseConfig):
self._validate_request_metadata(request_metadata)
output_config: Optional[OutputConfigBlock] = inference_params.pop("outputConfig", None)
+ inference_params.pop("output_config", None) # Bedrock Converse doesn't support it
# keep supported params in 'inference_params', and set all model-specific params in 'additional_request_params'
additional_request_params = {
diff --git a/litellm/llms/openai_like/providers.json b/litellm/llms/openai_like/providers.json
index b3125d4ad3..275c352b39 100644
--- a/litellm/llms/openai_like/providers.json
+++ b/litellm/llms/openai_like/providers.json
@@ -94,5 +94,12 @@
"assemblyai": {
"base_url": "https://llm-gateway.assemblyai.com/v1",
"api_key_env": "ASSEMBLYAI_API_KEY"
+ },
+ "charity_engine": {
+ "base_url": "https://api.charityengine.services/remotejobs/v2/inference",
+ "api_key_env": "CHARITY_ENGINE_API_KEY",
+ "param_mappings": {
+ "max_completion_tokens": "max_tokens"
+ }
}
}
diff --git a/litellm/llms/serper/search/__init__.py b/litellm/llms/serper/search/__init__.py
new file mode 100644
index 0000000000..cdb4bd4b53
--- /dev/null
+++ b/litellm/llms/serper/search/__init__.py
@@ -0,0 +1,6 @@
+"""
+Serper Search API module.
+"""
+from litellm.llms.serper.search.transformation import SerperSearchConfig
+
+__all__ = ["SerperSearchConfig"]
diff --git a/litellm/llms/serper/search/transformation.py b/litellm/llms/serper/search/transformation.py
new file mode 100644
index 0000000000..63526ea8ab
--- /dev/null
+++ b/litellm/llms/serper/search/transformation.py
@@ -0,0 +1,167 @@
+"""
+Calls Serper's /search endpoint to search Google.
+
+Serper API Reference: https://serper.dev
+"""
+from typing import Dict, List, Optional, TypedDict, Union
+
+import httpx
+
+from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
+from litellm.llms.base_llm.search.transformation import (
+ BaseSearchConfig,
+ SearchResponse,
+ SearchResult,
+)
+from litellm.secret_managers.main import get_secret_str
+
+
+class _SerperSearchRequestRequired(TypedDict):
+ """Required fields for Serper Search API request."""
+ q: str # Required - search query
+
+
+class SerperSearchRequest(_SerperSearchRequestRequired, total=False):
+ """
+ Serper Search API request format.
+ Based on: https://serper.dev
+ """
+ num: int # Optional - number of results to return, default 10
+ page: int # Optional - page number (default 1)
+ gl: str # Optional - country/geolocation code (e.g., "us", "gb")
+ hl: str # Optional - language code (e.g., "en", "de")
+ location: str # Optional - specific location for search targeting
+ autocorrect: bool # Optional - enable autocorrect (default True)
+ tbs: str # Optional - time-based search filter (e.g., "qdr:h", "qdr:d", "qdr:w")
+
+
+class SerperSearchConfig(BaseSearchConfig):
+ SERPER_API_BASE = "https://google.serper.dev"
+
+ @staticmethod
+ def ui_friendly_name() -> str:
+ return "Serper"
+
+ def validate_environment(
+ self,
+ headers: Dict,
+ api_key: Optional[str] = None,
+ api_base: Optional[str] = None,
+ **kwargs,
+ ) -> Dict:
+ """
+ Validate environment and return headers.
+ """
+ api_key = api_key or get_secret_str("SERPER_API_KEY")
+ if not api_key:
+ raise ValueError("SERPER_API_KEY is not set. Set `SERPER_API_KEY` environment variable.")
+ headers["X-API-KEY"] = api_key
+ headers["Content-Type"] = "application/json"
+ return headers
+
+ def get_complete_url(
+ self,
+ api_base: Optional[str],
+ optional_params: dict,
+ data: Optional[Union[Dict, List[Dict]]] = None,
+ **kwargs,
+ ) -> str:
+ """
+ Get complete URL for Search endpoint.
+ """
+ api_base = api_base or get_secret_str("SERPER_API_BASE") or self.SERPER_API_BASE
+ api_base = api_base.rstrip("/")
+
+ if not api_base.endswith("/search"):
+ api_base = f"{api_base}/search"
+
+ return api_base
+
+ def transform_search_request(
+ self,
+ query: Union[str, List[str]],
+ optional_params: dict,
+ **kwargs,
+ ) -> Dict:
+ """
+ Transform Search request to Serper API format.
+
+ Args:
+ query: Search query (string or list of strings). Serper only supports single string queries.
+ optional_params: Optional parameters for the request
+ - max_results: Maximum number of search results -> maps to `num`
+ - search_domain_filter: List of domains -> appended as site: clauses to `q`
+ - country: Country code filter (e.g., 'US', 'GB') -> maps to `gl` (lowercased)
+
+ Returns:
+ Dict with typed request data following SerperSearchRequest spec
+ """
+ if isinstance(query, list):
+ query = " ".join(query)
+
+ request_data: SerperSearchRequest = {
+ "q": query,
+ }
+
+ if "max_results" in optional_params:
+ request_data["num"] = optional_params["max_results"]
+
+ if "country" in optional_params:
+ request_data["gl"] = optional_params["country"].lower()
+
+ if "search_domain_filter" in optional_params:
+ domains = optional_params["search_domain_filter"]
+ if isinstance(domains, list) and len(domains) > 0:
+ domain_clauses = " OR ".join(f"site:{d}" for d in domains)
+ request_data["q"] = f"({request_data['q']}) ({domain_clauses})"
+
+ # Convert to dict before dynamic key assignments
+ result_data = dict(request_data)
+
+ # pass through all other parameters as-is
+ for param, value in optional_params.items():
+ if param not in self.get_supported_perplexity_optional_params() and param not in result_data:
+ result_data[param] = value
+
+ return result_data
+
+ def transform_search_response(
+ self,
+ raw_response: httpx.Response,
+ logging_obj: LiteLLMLoggingObj,
+ **kwargs,
+ ) -> SearchResponse:
+ """
+ Transform Serper API response to LiteLLM unified SearchResponse format.
+
+ Serper -> LiteLLM mappings:
+ - organic[].title -> SearchResult.title
+ - organic[].link -> SearchResult.url
+ - organic[].snippet -> SearchResult.snippet
+ - organic[].date -> SearchResult.date (optional, not always present)
+
+ Args:
+ raw_response: Raw httpx response from Serper API
+ logging_obj: Logging object for tracking
+
+ Returns:
+ SearchResponse with standardized format
+ """
+ response_json = raw_response.json()
+
+ results = []
+ for result in response_json.get("organic", []):
+ search_result = SearchResult(
+ title=result.get("title", ""),
+ url=result.get("link", ""),
+ snippet=result.get("snippet", ""),
+ date=result.get("date"),
+ last_updated=None,
+ )
+ results.append(search_result)
+
+ return SearchResponse(
+ results=results,
+ object="search",
+ )
+
diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json
index 900894f74d..194af4895f 100644
--- a/litellm/model_prices_and_context_window_backup.json
+++ b/litellm/model_prices_and_context_window_backup.json
@@ -4207,6 +4207,41 @@
"supports_tool_choice": true,
"supports_vision": true
},
+ "azure/gpt-5.3-chat": {
+ "cache_read_input_token_cost": 1.75e-07,
+ "cache_read_input_token_cost_priority": 3.5e-07,
+ "input_cost_per_token": 1.75e-06,
+ "input_cost_per_token_priority": 3.5e-06,
+ "litellm_provider": "azure",
+ "max_input_tokens": 128000,
+ "max_output_tokens": 16384,
+ "max_tokens": 16384,
+ "mode": "chat",
+ "output_cost_per_token": 1.4e-05,
+ "output_cost_per_token_priority": 2.8e-05,
+ "supported_endpoints": [
+ "/v1/chat/completions",
+ "/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
+ },
"azure/gpt-5.3-codex": {
"cache_read_input_token_cost": 1.75e-07,
"input_cost_per_token": 1.75e-06,
@@ -4299,6 +4334,160 @@
"supports_vision": true,
"supports_web_search": true
},
+ "azure/gpt-5.4": {
+ "cache_read_input_token_cost": 2.5e-07,
+ "cache_read_input_token_cost_above_272k_tokens": 5e-07,
+ "cache_read_input_token_cost_priority": 5e-07,
+ "cache_read_input_token_cost_above_272k_tokens_priority": 1e-06,
+ "input_cost_per_token": 2.5e-06,
+ "input_cost_per_token_above_272k_tokens": 5e-06,
+ "input_cost_per_token_priority": 5e-06,
+ "input_cost_per_token_above_272k_tokens_priority": 1e-05,
+ "litellm_provider": "azure",
+ "max_input_tokens": 1050000,
+ "max_output_tokens": 128000,
+ "max_tokens": 128000,
+ "mode": "chat",
+ "output_cost_per_token": 1.5e-05,
+ "output_cost_per_token_above_272k_tokens": 2.25e-05,
+ "output_cost_per_token_priority": 3e-05,
+ "output_cost_per_token_above_272k_tokens_priority": 4.5e-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
+ },
+ "azure/gpt-5.4-2026-03-05": {
+ "cache_read_input_token_cost": 2.5e-07,
+ "cache_read_input_token_cost_above_272k_tokens": 5e-07,
+ "cache_read_input_token_cost_priority": 5e-07,
+ "cache_read_input_token_cost_above_272k_tokens_priority": 1e-06,
+ "input_cost_per_token": 2.5e-06,
+ "input_cost_per_token_above_272k_tokens": 5e-06,
+ "input_cost_per_token_priority": 5e-06,
+ "input_cost_per_token_above_272k_tokens_priority": 1e-05,
+ "litellm_provider": "azure",
+ "max_input_tokens": 1050000,
+ "max_output_tokens": 128000,
+ "max_tokens": 128000,
+ "mode": "chat",
+ "output_cost_per_token": 1.5e-05,
+ "output_cost_per_token_above_272k_tokens": 2.25e-05,
+ "output_cost_per_token_priority": 3e-05,
+ "output_cost_per_token_above_272k_tokens_priority": 4.5e-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
+ },
+ "azure/gpt-5.4-pro": {
+ "cache_read_input_token_cost": 3e-06,
+ "cache_read_input_token_cost_above_272k_tokens": 6e-06,
+ "input_cost_per_token": 3e-05,
+ "input_cost_per_token_above_272k_tokens": 6e-05,
+ "litellm_provider": "azure",
+ "max_input_tokens": 1050000,
+ "max_output_tokens": 128000,
+ "max_tokens": 128000,
+ "mode": "responses",
+ "output_cost_per_token": 0.00018,
+ "output_cost_per_token_above_272k_tokens": 0.00027,
+ "supported_endpoints": [
+ "/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": false,
+ "supports_system_messages": true,
+ "supports_tool_choice": true,
+ "supports_vision": true,
+ "supports_web_search": true
+ },
+ "azure/gpt-5.4-pro-2026-03-05": {
+ "cache_read_input_token_cost": 3e-06,
+ "cache_read_input_token_cost_above_272k_tokens": 6e-06,
+ "input_cost_per_token": 3e-05,
+ "input_cost_per_token_above_272k_tokens": 6e-05,
+ "litellm_provider": "azure",
+ "max_input_tokens": 1050000,
+ "max_output_tokens": 128000,
+ "max_tokens": 128000,
+ "mode": "responses",
+ "output_cost_per_token": 0.00018,
+ "output_cost_per_token_above_272k_tokens": 0.00027,
+ "supported_endpoints": [
+ "/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": false,
+ "supports_system_messages": true,
+ "supports_tool_choice": true,
+ "supports_vision": true,
+ "supports_web_search": true
+ },
"azure/gpt-image-1": {
"cache_read_input_image_token_cost": 2.5e-06,
"cache_read_input_token_cost": 1.25e-06,
@@ -12090,6 +12279,14 @@
"notes": "SearXNG is an open-source metasearch engine. Free to use when self-hosted or using public instances."
}
},
+ "serper/search": {
+ "input_cost_per_query": 0.001,
+ "litellm_provider": "serper",
+ "mode": "search",
+ "metadata": {
+ "notes": "Serper Google Search API. Pricing: $1.00/1k queries (Starter), $0.75/1k (Standard), $0.50/1k (Scale), $0.30/1k (Ultimate)."
+ }
+ },
"elevenlabs/scribe_v1": {
"input_cost_per_second": 6.11e-05,
"litellm_provider": "elevenlabs",
@@ -16799,6 +16996,42 @@
"supports_vision": true,
"supports_web_search": true
},
+ "gemini/gemini-3.1-flash-image-preview": {
+ "input_cost_per_token": 2.5e-07,
+ "input_cost_per_token_batches": 1.25e-07,
+ "litellm_provider": "gemini",
+ "max_input_tokens": 65536,
+ "max_output_tokens": 32768,
+ "max_tokens": 32768,
+ "mode": "image_generation",
+ "output_cost_per_image": 0.045,
+ "output_cost_per_image_token": 6e-05,
+ "output_cost_per_image_token_batches": 3e-05,
+ "output_cost_per_token": 1.5e-06,
+ "output_cost_per_token_batches": 7.5e-07,
+ "rpm": 1000,
+ "tpm": 4000000,
+ "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3.1-flash-image-preview",
+ "supported_endpoints": [
+ "/v1/chat/completions",
+ "/v1/completions",
+ "/v1/batch"
+ ],
+ "supported_modalities": [
+ "text",
+ "image"
+ ],
+ "supported_output_modalities": [
+ "text",
+ "image"
+ ],
+ "supports_function_calling": false,
+ "supports_prompt_caching": true,
+ "supports_response_schema": true,
+ "supports_system_messages": true,
+ "supports_vision": true,
+ "supports_web_search": true
+ },
"gemini/deep-research-pro-preview-12-2025": {
"input_cost_per_image": 0.0011,
"input_cost_per_token": 2e-06,
@@ -21083,7 +21316,7 @@
"max_input_tokens": 1050000,
"max_output_tokens": 128000,
"max_tokens": 128000,
- "mode": "chat",
+ "mode": "responses",
"output_cost_per_token": 0.00018,
"output_cost_per_token_above_272k_tokens": 0.00027,
"output_cost_per_token_flex": 9e-05,
@@ -21091,9 +21324,8 @@
"output_cost_per_token_priority": 0.00027,
"output_cost_per_token_above_272k_tokens_priority": 0.000405,
"supported_endpoints": [
- "/v1/chat/completions",
- "/v1/batch",
- "/v1/responses"
+ "/v1/responses",
+ "/v1/batch"
],
"supported_modalities": [
"text",
@@ -21132,7 +21364,7 @@
"max_input_tokens": 1050000,
"max_output_tokens": 128000,
"max_tokens": 128000,
- "mode": "chat",
+ "mode": "responses",
"output_cost_per_token": 0.00018,
"output_cost_per_token_above_272k_tokens": 0.00027,
"output_cost_per_token_flex": 9e-05,
@@ -21140,9 +21372,8 @@
"output_cost_per_token_priority": 0.00027,
"output_cost_per_token_above_272k_tokens_priority": 0.000405,
"supported_endpoints": [
- "/v1/chat/completions",
- "/v1/batch",
- "/v1/responses"
+ "/v1/responses",
+ "/v1/batch"
],
"supported_modalities": [
"text",
diff --git a/litellm/provider_endpoints_support_backup.json b/litellm/provider_endpoints_support_backup.json
index fc79ba5475..ed54c707b0 100644
--- a/litellm/provider_endpoints_support_backup.json
+++ b/litellm/provider_endpoints_support_backup.json
@@ -2061,6 +2061,13 @@
"search": true
}
},
+ "serper": {
+ "display_name": "Serper (`serper`)",
+ "url": "https://docs.litellm.ai/docs/search/serper",
+ "endpoints": {
+ "search": true
+ }
+ },
"triton": {
"display_name": "Triton (`triton`)",
"url": "https://docs.litellm.ai/docs/providers/triton-inference-server",
diff --git a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py
index 5f6cb87b26..5ad3cf444f 100644
--- a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py
+++ b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py
@@ -92,7 +92,24 @@ def get_base_url(spec: Dict[str, Any], spec_path: Optional[str] = None) -> str:
"""Extract base URL from OpenAPI spec."""
# OpenAPI 3.x
if "servers" in spec and spec["servers"]:
- return spec["servers"][0]["url"]
+ server_url = spec["servers"][0]["url"]
+
+ # If the server URL is relative (starts with /), derive base from spec_path
+ if server_url.startswith("/") and spec_path:
+ if spec_path.startswith("http://") or spec_path.startswith("https://"):
+ # Extract base URL from spec_path (e.g., https://petstore3.swagger.io/api/v3/openapi.json)
+ # Combine domain with the relative server URL
+ from urllib.parse import urlparse
+ parsed = urlparse(spec_path)
+ base_domain = f"{parsed.scheme}://{parsed.netloc}"
+ full_base_url = base_domain + server_url
+ verbose_logger.info(
+ f"OpenAPI spec has relative server URL '{server_url}'. "
+ f"Deriving base from spec_path: {full_base_url}"
+ )
+ return full_base_url
+
+ return server_url
# OpenAPI 2.x (Swagger)
elif "host" in spec:
scheme = spec.get("schemes", ["https"])[0]
diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py
index 99f6a5234a..7898f03e01 100644
--- a/litellm/proxy/_experimental/mcp_server/server.py
+++ b/litellm/proxy/_experimental/mcp_server/server.py
@@ -711,6 +711,7 @@ if MCP_AVAILABLE:
Checks both the full tool name and unprefixed version (without server prefix).
This allows users to configure simple tool names regardless of prefixing.
+ Comparison is case-insensitive to handle OpenAPI operationIds that may be in camelCase.
Args:
tool_name: The tool name to check (may be prefixed like "server-tool_name")
@@ -723,13 +724,15 @@ if MCP_AVAILABLE:
split_server_prefix_from_name,
)
- # Check if the full name is in the list
- if tool_name in filter_list:
+ # Normalize filter list to lowercase for case-insensitive comparison
+ filter_list_lower = [f.lower() for f in filter_list]
+
+ if tool_name.lower() in filter_list_lower:
return True
- # Check if the unprefixed name is in the list
+ # Check if the unprefixed name is in the list (case-insensitive)
unprefixed_name, _ = split_server_prefix_from_name(tool_name)
- return unprefixed_name in filter_list
+ return unprefixed_name.lower() in filter_list_lower
def filter_tools_by_allowed_tools(
tools: List[MCPTool],
diff --git a/litellm/proxy/auth/model_checks.py b/litellm/proxy/auth/model_checks.py
index 32f209a763..13b26eef43 100644
--- a/litellm/proxy/auth/model_checks.py
+++ b/litellm/proxy/auth/model_checks.py
@@ -108,16 +108,23 @@ def get_key_models(
"""
all_models: List[str] = []
if len(user_api_key_dict.models) > 0:
- all_models = user_api_key_dict.models
+ all_models = list(user_api_key_dict.models) # copy to avoid mutating cached objects
if SpecialModelNames.all_team_models.value in all_models:
- all_models = user_api_key_dict.team_models
+ all_models = list(user_api_key_dict.team_models) # copy to avoid mutating cached objects
if SpecialModelNames.all_proxy_models.value in all_models:
- all_models = proxy_model_list
+ all_models = list(proxy_model_list) # copy to avoid mutating caller's list
+ if include_model_access_groups:
+ all_models.extend(model_access_groups.keys())
all_models = _get_models_from_access_groups(
- model_access_groups=model_access_groups, all_models=all_models
+ model_access_groups=model_access_groups,
+ all_models=all_models,
+ include_model_access_groups=include_model_access_groups,
)
+ # deduplicate while preserving order
+ all_models = list(dict.fromkeys(all_models))
+
verbose_proxy_logger.debug("ALL KEY MODELS - {}".format(len(all_models)))
return all_models
@@ -141,8 +148,8 @@ def get_team_models(
all_models_set.update(team_models)
if SpecialModelNames.all_proxy_models.value in all_models_set:
all_models_set.update(proxy_model_list)
-
- all_models = list(all_models_set)
+ if include_model_access_groups:
+ all_models_set.update(model_access_groups.keys())
all_models = _get_models_from_access_groups(
model_access_groups=model_access_groups,
@@ -150,6 +157,9 @@ def get_team_models(
include_model_access_groups=include_model_access_groups,
)
+ # deduplicate while preserving order
+ all_models = list(dict.fromkeys(all_models))
+
verbose_proxy_logger.debug("ALL TEAM MODELS - {}".format(len(all_models)))
return all_models
diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py
index 94cb7510a5..c992cfb53e 100644
--- a/litellm/proxy/auth/user_api_key_auth.py
+++ b/litellm/proxy/auth/user_api_key_auth.py
@@ -615,17 +615,23 @@ async def _user_api_key_auth_builder( # noqa: PLR0915
# This allows UI SSO to work separately from API M2M authentication
# Note: Info routes are already scoped to the user
if RouteChecks.is_llm_api_route(route=route) or RouteChecks.is_info_route(route=route):
- # return UserAPIKeyAuth object
- # helper to check if the api_key is a valid oauth2 token
- from litellm.proxy.proxy_server import premium_user
+ # When both OAuth2 and JWT auth are enabled, use token format to decide:
+ # - JWT tokens (3 dot-separated parts) -> skip OAuth2, fall through to JWT handler
+ # - Opaque tokens -> use OAuth2 handler
+ # This allows JWT for users and OAuth2 for M2M on the same instance
+ is_jwt_token = jwt_handler.is_jwt(token=api_key) if general_settings.get("enable_jwt_auth", False) is True else False
+ if not is_jwt_token:
+ # return UserAPIKeyAuth object
+ # helper to check if the api_key is a valid oauth2 token
+ from litellm.proxy.proxy_server import premium_user
- if premium_user is not True:
- raise ValueError(
- "Oauth2 token validation is only available for premium users"
- + CommonProxyErrors.not_premium_user.value
- )
+ if premium_user is not True:
+ raise ValueError(
+ "Oauth2 token validation is only available for premium users"
+ + CommonProxyErrors.not_premium_user.value
+ )
- return await Oauth2Handler.check_oauth2_token(token=api_key)
+ return await Oauth2Handler.check_oauth2_token(token=api_key)
if general_settings.get("enable_oauth2_proxy_auth", False) is True:
return await handle_oauth2_proxy_request(request=request)
diff --git a/litellm/proxy/batches_endpoints/endpoints.py b/litellm/proxy/batches_endpoints/endpoints.py
index 07fb4a0de8..3fdebd423e 100644
--- a/litellm/proxy/batches_endpoints/endpoints.py
+++ b/litellm/proxy/batches_endpoints/endpoints.py
@@ -27,6 +27,7 @@ from litellm.proxy.openai_files_endpoints.common_utils import (
encode_file_id_with_model,
get_batch_from_database,
get_credentials_for_model,
+ get_model_id_from_unified_batch_id,
get_models_from_unified_file_id,
get_original_file_id,
prepare_data_with_credentials,
@@ -487,6 +488,10 @@ async def retrieve_batch( # noqa: PLR0915
response = await llm_router.aretrieve_batch(**data) # type: ignore
response._hidden_params["unified_batch_id"] = unified_batch_id
+ if unified_batch_id:
+ model_id_from_batch = get_model_id_from_unified_batch_id(unified_batch_id)
+ if model_id_from_batch:
+ response._hidden_params["model_id"] = model_id_from_batch
# SCENARIO 3: Fallback to custom_llm_provider (uses env variables)
else:
diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py
index 06338a33e4..80094c9abd 100644
--- a/litellm/proxy/management_endpoints/internal_user_endpoints.py
+++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py
@@ -1844,7 +1844,13 @@ async def delete_user(
## DELETE ASSOCIATED INVITATION LINKS
await prisma_client.db.litellm_invitationlink.delete_many(
- where={"user_id": {"in": data.user_ids}}
+ where={
+ "OR": [
+ {"user_id": {"in": data.user_ids}},
+ {"created_by": {"in": data.user_ids}},
+ {"updated_by": {"in": data.user_ids}},
+ ]
+ }
)
## DELETE ASSOCIATED ORGANIZATION MEMBERSHIPS
diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py
index 633de86aa6..ee1868fc74 100644
--- a/litellm/proxy/management_endpoints/team_endpoints.py
+++ b/litellm/proxy/management_endpoints/team_endpoints.py
@@ -2827,21 +2827,6 @@ async def validate_membership(
)
-def _unfurl_all_proxy_models(
- team_info: LiteLLM_TeamTable, llm_router: Router
-) -> LiteLLM_TeamTable:
- if (
- SpecialModelNames.all_proxy_models.value in team_info.models
- and llm_router is not None
- ):
- team_models: set[str] = set() # make set to avoid duplicates
- for model in team_info.models:
- if model != SpecialModelNames.all_proxy_models.value:
- team_models.add(model)
- for model in llm_router.get_model_names():
- team_models.add(model)
- team_info.models = list(team_models)
- return team_info
async def _add_team_member_budget_table(
@@ -2972,9 +2957,6 @@ async def team_info(
team_info_response_object=_team_info,
)
- # ## UNFURL 'all-proxy-models' into the team_info.models list ##
- # if llm_router is not None:
- # _team_info = _unfurl_all_proxy_models(_team_info, llm_router)
response_object = TeamInfoResponseObject(
team_id=team_id,
team_info=_team_info,
diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py
index bd7b21c3b5..f3bc4b0803 100644
--- a/litellm/proxy/proxy_server.py
+++ b/litellm/proxy/proxy_server.py
@@ -377,9 +377,6 @@ from litellm.proxy.management_endpoints.internal_user_endpoints import user_upda
from litellm.proxy.management_endpoints.jwt_key_mapping_endpoints import (
router as jwt_key_mapping_router,
)
-from litellm.proxy.management_endpoints.jwt_key_mapping_endpoints import (
- router as jwt_key_mapping_router,
-)
from litellm.proxy.management_endpoints.key_management_endpoints import (
delete_verification_tokens,
duration_in_seconds,
diff --git a/litellm/types/router.py b/litellm/types/router.py
index d917d845ad..f0c1ea5e32 100644
--- a/litellm/types/router.py
+++ b/litellm/types/router.py
@@ -8,7 +8,7 @@ from dataclasses import dataclass
from typing import Any, Dict, List, Literal, Optional, Tuple, Union, get_type_hints
import httpx
-from pydantic import BaseModel, ConfigDict, Field
+from pydantic import BaseModel, ConfigDict, Field, model_validator
from typing_extensions import Required, TypedDict
from litellm._uuid import uuid
@@ -16,7 +16,6 @@ from litellm._uuid import uuid
from .completion import CompletionRequest
from .embedding import EmbeddingRequest
from .llms.openai import OpenAIFileObject
-from .llms.vertex_ai import VERTEX_CREDENTIALS_TYPES
from .search import SearchProvider
from .utils import CustomPricingLiteLLMParams, ModelResponse
@@ -162,6 +161,9 @@ class CredentialLiteLLMParams(BaseModel):
watsonx_region_name: Optional[str] = None
+_RESERVED_INIT_KEYS = frozenset({"self", "params", "__class__"})
+
+
class GenericLiteLLMParams(CredentialLiteLLMParams, CustomPricingLiteLLMParams):
"""
LiteLLM Params without 'model' arg (used across completion / assistants api)
@@ -215,76 +217,21 @@ class GenericLiteLLMParams(CredentialLiteLLMParams, CustomPricingLiteLLMParams):
vector_store_id: Optional[str] = None
milvus_text_field: Optional[str] = None
- def __init__(
- self,
- custom_llm_provider: Optional[str] = None,
- max_retries: Optional[Union[int, str]] = None,
- tpm: Optional[int] = None,
- rpm: Optional[int] = None,
- api_key: Optional[str] = None,
- api_base: Optional[str] = None,
- api_version: Optional[str] = None,
- timeout: Optional[Union[float, str]] = None, # if str, pass in as os.environ/
- stream_timeout: Optional[Union[float, str]] = (
- None # timeout when making stream=True calls, if str, pass in as os.environ/
- ),
- organization: Optional[str] = None, # for openai orgs
- ## LOGGING PARAMS ##
- litellm_trace_id: Optional[str] = None,
- ## UNIFIED PROJECT/REGION ##
- region_name: Optional[str] = None,
- ## VERTEX AI ##
- vertex_project: Optional[str] = None,
- vertex_location: Optional[str] = None,
- vertex_credentials: Optional[VERTEX_CREDENTIALS_TYPES] = None,
- ## AWS BEDROCK / SAGEMAKER ##
- aws_access_key_id: Optional[str] = None,
- aws_secret_access_key: Optional[str] = None,
- aws_region_name: Optional[str] = None,
- ## IBM WATSONX ##
- watsonx_region_name: Optional[str] = None,
- input_cost_per_token: Optional[float] = None,
- output_cost_per_token: Optional[float] = None,
- input_cost_per_second: Optional[float] = None,
- output_cost_per_second: Optional[float] = None,
- max_file_size_mb: Optional[float] = None,
- # Deployment budgets
- max_budget: Optional[float] = None,
- budget_duration: Optional[str] = None,
- # Pass through params
- use_in_pass_through: Optional[bool] = False,
- # Dynamic param to force using litellm proxy
- use_litellm_proxy: Optional[bool] = False,
- # This will merge the reasoning content in the choices
- merge_reasoning_content_in_choices: Optional[bool] = False,
- model_info: Optional[Dict] = None,
- mock_response: Optional[Union[str, ModelResponse, Exception, Any]] = None,
- # auto-router params
- auto_router_config_path: Optional[str] = None,
- auto_router_config: Optional[str] = None,
- auto_router_default_model: Optional[str] = None,
- auto_router_embedding_model: Optional[str] = None,
- # complexity-router params
- complexity_router_config: Optional[Dict] = None,
- complexity_router_default_model: Optional[str] = None,
- # Batch/File API Params
- s3_bucket_name: Optional[str] = None,
- s3_encryption_key_id: Optional[str] = None,
- gcs_bucket_name: Optional[str] = None,
- **params,
- ):
- args = locals()
- args.pop("max_retries", None)
- args.pop("self", None)
- args.pop("params", None)
- args.pop("__class__", None)
- if max_retries is not None and isinstance(max_retries, str):
- max_retries = int(max_retries) # cast to int
- # We need to keep max_retries in args since it's a parameter of GenericLiteLLMParams
- args[
- "max_retries"
- ] = max_retries # Put max_retries back in args after popping it
- super().__init__(**args, **params)
+ @model_validator(mode="before")
+ @classmethod
+ def preprocess_input_data(cls, data: Any) -> Any:
+ """
+ Pre-process input data before validation:
+ 1. Filter out reserved Python keywords ('self', 'params', '__class__') to prevent
+ 'got multiple values for argument' errors when user data contains these keys.
+ 2. Convert max_retries from string to int if needed.
+ """
+ if isinstance(data, dict):
+ filtered = {k: v for k, v in data.items() if k not in _RESERVED_INIT_KEYS}
+ if "max_retries" in filtered and isinstance(filtered["max_retries"], str):
+ filtered["max_retries"] = int(filtered["max_retries"])
+ return filtered
+ return data
def __contains__(self, key):
# Define custom behavior for the 'in' operator
@@ -311,46 +258,6 @@ class LiteLLM_Params(GenericLiteLLMParams):
model: str
model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True)
- def __init__(
- self,
- model: str,
- custom_llm_provider: Optional[str] = None,
- max_retries: Optional[Union[int, str]] = None,
- tpm: Optional[int] = None,
- rpm: Optional[int] = None,
- api_key: Optional[str] = None,
- api_base: Optional[str] = None,
- api_version: Optional[str] = None,
- timeout: Optional[Union[float, str]] = None, # if str, pass in as os.environ/
- stream_timeout: Optional[Union[float, str]] = (
- None # timeout when making stream=True calls, if str, pass in as os.environ/
- ),
- organization: Optional[str] = None, # for openai orgs
- ## VERTEX AI ##
- vertex_project: Optional[str] = None,
- vertex_location: Optional[str] = None,
- ## AWS BEDROCK / SAGEMAKER ##
- aws_access_key_id: Optional[str] = None,
- aws_secret_access_key: Optional[str] = None,
- aws_region_name: Optional[str] = None,
- # OpenAI / Azure Whisper
- # set a max-size of file that can be passed to litellm proxy
- max_file_size_mb: Optional[float] = None,
- # will use deployment on pass-through endpoints if True
- use_in_pass_through: Optional[bool] = False,
- use_litellm_proxy: Optional[bool] = False,
- **params,
- ):
- args = locals()
- args.pop("max_retries", None)
- args.pop("self", None)
- args.pop("params", None)
- args.pop("__class__", None)
- if max_retries is not None and isinstance(max_retries, str):
- max_retries = int(max_retries) # cast to int
- args["max_retries"] = max_retries
- super().__init__(**{**args, **params})
-
def __contains__(self, key):
# Define custom behavior for the 'in' operator
return hasattr(self, key)
diff --git a/litellm/types/utils.py b/litellm/types/utils.py
index 67b6c3ea0a..b5d5c06924 100644
--- a/litellm/types/utils.py
+++ b/litellm/types/utils.py
@@ -3177,6 +3177,7 @@ class LlmProviders(str, Enum):
TOPAZ = "topaz"
SAP_GENERATIVE_AI_HUB = "sap"
ASSEMBLYAI = "assemblyai"
+ CHARITY_ENGINE = "charity_engine"
GITHUB_COPILOT = "github_copilot"
SNOWFLAKE = "snowflake"
GRADIENT_AI = "gradient_ai"
@@ -3249,6 +3250,7 @@ class SearchProviders(str, Enum):
LINKUP = "linkup"
DUCKDUCKGO = "duckduckgo"
SEARCHAPI = "searchapi"
+ SERPER = "serper"
# Create a set of all search provider values for quick lookup
diff --git a/litellm/utils.py b/litellm/utils.py
index dfacefe697..b7caf0edd7 100644
--- a/litellm/utils.py
+++ b/litellm/utils.py
@@ -8884,6 +8884,7 @@ class ProviderConfigManager:
from litellm.llms.perplexity.search.transformation import PerplexitySearchConfig
from litellm.llms.searchapi.search.transformation import SearchAPIConfig
from litellm.llms.searxng.search.transformation import SearXNGSearchConfig
+ from litellm.llms.serper.search.transformation import SerperSearchConfig
from litellm.llms.tavily.search.transformation import TavilySearchConfig
PROVIDER_TO_CONFIG_MAP = {
@@ -8899,6 +8900,7 @@ class ProviderConfigManager:
SearchProviders.LINKUP: LinkupSearchConfig,
SearchProviders.DUCKDUCKGO: DuckDuckGoSearchConfig,
SearchProviders.SEARCHAPI: SearchAPIConfig,
+ SearchProviders.SERPER: SerperSearchConfig,
}
config_class = PROVIDER_TO_CONFIG_MAP.get(provider, None)
if config_class is None:
diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json
index 900894f74d..194af4895f 100644
--- a/model_prices_and_context_window.json
+++ b/model_prices_and_context_window.json
@@ -4207,6 +4207,41 @@
"supports_tool_choice": true,
"supports_vision": true
},
+ "azure/gpt-5.3-chat": {
+ "cache_read_input_token_cost": 1.75e-07,
+ "cache_read_input_token_cost_priority": 3.5e-07,
+ "input_cost_per_token": 1.75e-06,
+ "input_cost_per_token_priority": 3.5e-06,
+ "litellm_provider": "azure",
+ "max_input_tokens": 128000,
+ "max_output_tokens": 16384,
+ "max_tokens": 16384,
+ "mode": "chat",
+ "output_cost_per_token": 1.4e-05,
+ "output_cost_per_token_priority": 2.8e-05,
+ "supported_endpoints": [
+ "/v1/chat/completions",
+ "/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
+ },
"azure/gpt-5.3-codex": {
"cache_read_input_token_cost": 1.75e-07,
"input_cost_per_token": 1.75e-06,
@@ -4299,6 +4334,160 @@
"supports_vision": true,
"supports_web_search": true
},
+ "azure/gpt-5.4": {
+ "cache_read_input_token_cost": 2.5e-07,
+ "cache_read_input_token_cost_above_272k_tokens": 5e-07,
+ "cache_read_input_token_cost_priority": 5e-07,
+ "cache_read_input_token_cost_above_272k_tokens_priority": 1e-06,
+ "input_cost_per_token": 2.5e-06,
+ "input_cost_per_token_above_272k_tokens": 5e-06,
+ "input_cost_per_token_priority": 5e-06,
+ "input_cost_per_token_above_272k_tokens_priority": 1e-05,
+ "litellm_provider": "azure",
+ "max_input_tokens": 1050000,
+ "max_output_tokens": 128000,
+ "max_tokens": 128000,
+ "mode": "chat",
+ "output_cost_per_token": 1.5e-05,
+ "output_cost_per_token_above_272k_tokens": 2.25e-05,
+ "output_cost_per_token_priority": 3e-05,
+ "output_cost_per_token_above_272k_tokens_priority": 4.5e-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
+ },
+ "azure/gpt-5.4-2026-03-05": {
+ "cache_read_input_token_cost": 2.5e-07,
+ "cache_read_input_token_cost_above_272k_tokens": 5e-07,
+ "cache_read_input_token_cost_priority": 5e-07,
+ "cache_read_input_token_cost_above_272k_tokens_priority": 1e-06,
+ "input_cost_per_token": 2.5e-06,
+ "input_cost_per_token_above_272k_tokens": 5e-06,
+ "input_cost_per_token_priority": 5e-06,
+ "input_cost_per_token_above_272k_tokens_priority": 1e-05,
+ "litellm_provider": "azure",
+ "max_input_tokens": 1050000,
+ "max_output_tokens": 128000,
+ "max_tokens": 128000,
+ "mode": "chat",
+ "output_cost_per_token": 1.5e-05,
+ "output_cost_per_token_above_272k_tokens": 2.25e-05,
+ "output_cost_per_token_priority": 3e-05,
+ "output_cost_per_token_above_272k_tokens_priority": 4.5e-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
+ },
+ "azure/gpt-5.4-pro": {
+ "cache_read_input_token_cost": 3e-06,
+ "cache_read_input_token_cost_above_272k_tokens": 6e-06,
+ "input_cost_per_token": 3e-05,
+ "input_cost_per_token_above_272k_tokens": 6e-05,
+ "litellm_provider": "azure",
+ "max_input_tokens": 1050000,
+ "max_output_tokens": 128000,
+ "max_tokens": 128000,
+ "mode": "responses",
+ "output_cost_per_token": 0.00018,
+ "output_cost_per_token_above_272k_tokens": 0.00027,
+ "supported_endpoints": [
+ "/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": false,
+ "supports_system_messages": true,
+ "supports_tool_choice": true,
+ "supports_vision": true,
+ "supports_web_search": true
+ },
+ "azure/gpt-5.4-pro-2026-03-05": {
+ "cache_read_input_token_cost": 3e-06,
+ "cache_read_input_token_cost_above_272k_tokens": 6e-06,
+ "input_cost_per_token": 3e-05,
+ "input_cost_per_token_above_272k_tokens": 6e-05,
+ "litellm_provider": "azure",
+ "max_input_tokens": 1050000,
+ "max_output_tokens": 128000,
+ "max_tokens": 128000,
+ "mode": "responses",
+ "output_cost_per_token": 0.00018,
+ "output_cost_per_token_above_272k_tokens": 0.00027,
+ "supported_endpoints": [
+ "/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": false,
+ "supports_system_messages": true,
+ "supports_tool_choice": true,
+ "supports_vision": true,
+ "supports_web_search": true
+ },
"azure/gpt-image-1": {
"cache_read_input_image_token_cost": 2.5e-06,
"cache_read_input_token_cost": 1.25e-06,
@@ -12090,6 +12279,14 @@
"notes": "SearXNG is an open-source metasearch engine. Free to use when self-hosted or using public instances."
}
},
+ "serper/search": {
+ "input_cost_per_query": 0.001,
+ "litellm_provider": "serper",
+ "mode": "search",
+ "metadata": {
+ "notes": "Serper Google Search API. Pricing: $1.00/1k queries (Starter), $0.75/1k (Standard), $0.50/1k (Scale), $0.30/1k (Ultimate)."
+ }
+ },
"elevenlabs/scribe_v1": {
"input_cost_per_second": 6.11e-05,
"litellm_provider": "elevenlabs",
@@ -16799,6 +16996,42 @@
"supports_vision": true,
"supports_web_search": true
},
+ "gemini/gemini-3.1-flash-image-preview": {
+ "input_cost_per_token": 2.5e-07,
+ "input_cost_per_token_batches": 1.25e-07,
+ "litellm_provider": "gemini",
+ "max_input_tokens": 65536,
+ "max_output_tokens": 32768,
+ "max_tokens": 32768,
+ "mode": "image_generation",
+ "output_cost_per_image": 0.045,
+ "output_cost_per_image_token": 6e-05,
+ "output_cost_per_image_token_batches": 3e-05,
+ "output_cost_per_token": 1.5e-06,
+ "output_cost_per_token_batches": 7.5e-07,
+ "rpm": 1000,
+ "tpm": 4000000,
+ "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3.1-flash-image-preview",
+ "supported_endpoints": [
+ "/v1/chat/completions",
+ "/v1/completions",
+ "/v1/batch"
+ ],
+ "supported_modalities": [
+ "text",
+ "image"
+ ],
+ "supported_output_modalities": [
+ "text",
+ "image"
+ ],
+ "supports_function_calling": false,
+ "supports_prompt_caching": true,
+ "supports_response_schema": true,
+ "supports_system_messages": true,
+ "supports_vision": true,
+ "supports_web_search": true
+ },
"gemini/deep-research-pro-preview-12-2025": {
"input_cost_per_image": 0.0011,
"input_cost_per_token": 2e-06,
@@ -21083,7 +21316,7 @@
"max_input_tokens": 1050000,
"max_output_tokens": 128000,
"max_tokens": 128000,
- "mode": "chat",
+ "mode": "responses",
"output_cost_per_token": 0.00018,
"output_cost_per_token_above_272k_tokens": 0.00027,
"output_cost_per_token_flex": 9e-05,
@@ -21091,9 +21324,8 @@
"output_cost_per_token_priority": 0.00027,
"output_cost_per_token_above_272k_tokens_priority": 0.000405,
"supported_endpoints": [
- "/v1/chat/completions",
- "/v1/batch",
- "/v1/responses"
+ "/v1/responses",
+ "/v1/batch"
],
"supported_modalities": [
"text",
@@ -21132,7 +21364,7 @@
"max_input_tokens": 1050000,
"max_output_tokens": 128000,
"max_tokens": 128000,
- "mode": "chat",
+ "mode": "responses",
"output_cost_per_token": 0.00018,
"output_cost_per_token_above_272k_tokens": 0.00027,
"output_cost_per_token_flex": 9e-05,
@@ -21140,9 +21372,8 @@
"output_cost_per_token_priority": 0.00027,
"output_cost_per_token_above_272k_tokens_priority": 0.000405,
"supported_endpoints": [
- "/v1/chat/completions",
- "/v1/batch",
- "/v1/responses"
+ "/v1/responses",
+ "/v1/batch"
],
"supported_modalities": [
"text",
diff --git a/poetry.lock b/poetry.lock
index bad6a75b6e..c63d0df793 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -3222,15 +3222,15 @@ files = [
[[package]]
name = "litellm-proxy-extras"
-version = "0.4.52"
+version = "0.4.53"
description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package."
optional = true
python-versions = "!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,!=3.7.*,>=3.8"
groups = ["main"]
markers = "extra == \"proxy\""
files = [
- {file = "litellm_proxy_extras-0.4.52-py3-none-any.whl", hash = "sha256:5cdfeb5b93f6e4329299b3eabdb1e51beb264b075e1b5179149d8ded084b4aaa"},
- {file = "litellm_proxy_extras-0.4.52.tar.gz", hash = "sha256:fcac06b212ef12bb0f79fe465680f2f0e85e4aaab9234780fd3dc18e3598e743"},
+ {file = "litellm_proxy_extras-0.4.53-py3-none-any.whl", hash = "sha256:9224c667144774b6119e4de9b4b2d52fafc58442e6db317785c43b2d833665d6"},
+ {file = "litellm_proxy_extras-0.4.53.tar.gz", hash = "sha256:22c53fa8890d93d4a0d24171726e4e2bba8be6fef4838317cb74284fa9d27f70"},
]
[[package]]
@@ -8002,4 +8002,4 @@ utils = ["numpydoc"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.9,<4.0"
-content-hash = "fa110a048c30d0ad4e66414290ec103dba7707d99474827ea0cf3e4a2058d165"
+content-hash = "3036cfcdc06fb4293e248a2edd9c32a7afe6846920167527e247b2aefd74cfa6"
diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json
index 93c2c6d295..0b3f87fbe0 100644
--- a/provider_endpoints_support.json
+++ b/provider_endpoints_support.json
@@ -458,6 +458,24 @@
"interactions": true
}
},
+ "charity_engine": {
+ "display_name": "Charity Engine (`charity_engine`)",
+ "url": "https://docs.litellm.ai/docs/providers/charity_engine",
+ "endpoints": {
+ "chat_completions": true,
+ "messages": true,
+ "responses": true,
+ "embeddings": false,
+ "image_generations": false,
+ "audio_transcriptions": false,
+ "audio_speech": false,
+ "moderations": false,
+ "batches": false,
+ "rerank": false,
+ "a2a": false,
+ "interactions": false
+ }
+ },
"chutes": {
"display_name": "Chutes (`chutes`)",
"endpoints": {
@@ -2061,6 +2079,13 @@
"search": true
}
},
+ "serper": {
+ "display_name": "Serper (`serper`)",
+ "url": "https://docs.litellm.ai/docs/search/serper",
+ "endpoints": {
+ "search": true
+ }
+ },
"triton": {
"display_name": "Triton (`triton`)",
"url": "https://docs.litellm.ai/docs/providers/triton-inference-server",
diff --git a/pyproject.toml b/pyproject.toml
index 346e911464..dd8747b664 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -61,7 +61,7 @@ boto3 = { version = "^1.40.76", optional = true }
redisvl = {version = "^0.4.1", optional = true, markers = "python_version >= '3.9' and python_version < '3.14'"}
mcp = {version = ">=1.25.0,<2.0.0", optional = true, python = ">=3.10"}
a2a-sdk = {version = "^0.3.22", optional = true, python = ">=3.10"}
-litellm-proxy-extras = {version = "^0.4.52", optional = true}
+litellm-proxy-extras = {version = "^0.4.53", optional = true}
rich = {version = "^13.7.1", optional = true}
litellm-enterprise = {version = "^0.1.33", optional = true}
diskcache = {version = "^5.6.1", optional = true}
diff --git a/requirements.txt b/requirements.txt
index 103b298145..ccbfa281d9 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -57,7 +57,7 @@ grpcio>=1.75.0; python_version >= "3.14"
sentry_sdk==2.21.0 # for sentry error handling
detect-secrets==1.5.0 # Enterprise - secret detection / masking in LLM requests
tzdata==2025.1 # IANA time zone database
-litellm-proxy-extras==0.4.52 # for proxy extras - e.g. prisma migrations
+litellm-proxy-extras==0.4.53 # for proxy extras - e.g. prisma migrations
llm-sandbox==0.3.31 # for skill execution in sandbox
### LITELLM PACKAGE DEPENDENCIES
python-dotenv==1.0.1 # for env
@@ -80,4 +80,4 @@ pypdf>=6.7.3 # for PDF text extraction in RAG ingestion (CVE-2026-27888)
########################
# LITELLM ENTERPRISE DEPENDENCIES
########################
-litellm-enterprise==0.1.33
+litellm-enterprise==0.1.34
diff --git a/tests/code_coverage_tests/enforce_llms_folder_style.py b/tests/code_coverage_tests/enforce_llms_folder_style.py
index b39c669308..5aa993eb18 100644
--- a/tests/code_coverage_tests/enforce_llms_folder_style.py
+++ b/tests/code_coverage_tests/enforce_llms_folder_style.py
@@ -18,6 +18,7 @@ SEARCH_PROVIDERS = [
"linkup",
"duckduckgo",
"searchapi",
+ "serper",
]
ALLOWED_FILES_IN_LLMS_FOLDER = [
diff --git a/tests/image_gen_tests/test_image_variation.py b/tests/image_gen_tests/test_image_variation.py
index c0ad5d38e6..301835057a 100644
--- a/tests/image_gen_tests/test_image_variation.py
+++ b/tests/image_gen_tests/test_image_variation.py
@@ -42,44 +42,52 @@ def image_url():
image_file = BytesIO()
img.save(image_file, format="PNG")
image_file.seek(0)
+ # openai>=2.24.0 requires BytesIO to have .name for MIME type detection in multipart uploads
+ image_file.name = "litellm_logo.png"
return image_file
-def test_openai_image_variation_openai_sdk(image_url):
- from openai import OpenAI
-
- client = OpenAI()
- response = client.images.create_variation(image=image_url, n=2, size="1024x1024")
- print(response)
+# Commented out: OpenAI /images/variations endpoint deprecated (DALL-E 2 shutdown May 12, 2026)
+# def test_openai_image_variation_openai_sdk(image_url):
+# from openai import OpenAI
+#
+# client = OpenAI()
+# response = client.images.create_variation(image=image_url, n=2, size="1024x1024")
+# print(response)
+#
+#
+# @pytest.mark.parametrize("sync_mode", [True, False])
+# @pytest.mark.asyncio
+# async def test_openai_image_variation_litellm_sdk(image_url, sync_mode):
+# from litellm import image_variation, aimage_variation
+#
+# if sync_mode:
+# image_variation(image=image_url, n=2, size="1024x1024")
+# else:
+# await aimage_variation(image=image_url, n=2, size="1024x1024")
+#
+#
+# def test_topaz_image_variation(image_url):
+# from litellm import image_variation, aimage_variation
+# from litellm.llms.custom_httpx.http_handler import HTTPHandler
+# from unittest.mock import patch
+#
+# client = HTTPHandler()
+# with patch.object(client, "post") as mock_post:
+# try:
+# image_variation(
+# model="topaz/Standard V2",
+# image=image_url,
+# n=2,
+# size="1024x1024",
+# client=client,
+# )
+# except Exception as e:
+# print(e)
+# mock_post.assert_called_once()
-@pytest.mark.parametrize("sync_mode", [True, False])
-@pytest.mark.asyncio
-async def test_openai_image_variation_litellm_sdk(image_url, sync_mode):
- from litellm import image_variation, aimage_variation
-
- if sync_mode:
- image_variation(image=image_url, n=2, size="1024x1024")
- else:
- await aimage_variation(image=image_url, n=2, size="1024x1024")
-
-
-def test_topaz_image_variation(image_url):
- from litellm import image_variation, aimage_variation
- from litellm.llms.custom_httpx.http_handler import HTTPHandler
- from unittest.mock import patch
-
- client = HTTPHandler()
- with patch.object(client, "post") as mock_post:
- try:
- image_variation(
- model="topaz/Standard V2",
- image=image_url,
- n=2,
- size="1024x1024",
- client=client,
- )
- except Exception as e:
- print(e)
- mock_post.assert_called_once()
+def test_image_variation_placeholder():
+ """Placeholder: variation tests commented out - OpenAI /images/variations deprecated (DALL-E 2 shutdown May 12, 2026)."""
+ pass
diff --git a/tests/llm_translation/base_llm_unit_tests.py b/tests/llm_translation/base_llm_unit_tests.py
index ffdcd1b79f..d65735a620 100644
--- a/tests/llm_translation/base_llm_unit_tests.py
+++ b/tests/llm_translation/base_llm_unit_tests.py
@@ -868,8 +868,9 @@ class BaseLLMChatTest(ABC):
base_completion_call_args = self.get_base_completion_call_args()
if not supports_vision(base_completion_call_args["model"], None):
pytest.skip("Model does not support image input")
- elif "http://" in image_url and "fireworks_ai" in base_completion_call_args.get(
- "model"
+ elif "http://" in image_url and (
+ "fireworks_ai" in base_completion_call_args.get("model", "")
+ or "mistral" in base_completion_call_args.get("model", "")
):
pytest.skip("Model does not support http:// input")
diff --git a/tests/llm_translation/test_skills_api.py b/tests/llm_translation/test_skills_api.py
index 773165dd0a..b340167133 100644
--- a/tests/llm_translation/test_skills_api.py
+++ b/tests/llm_translation/test_skills_api.py
@@ -23,27 +23,48 @@ from litellm.types.llms.anthropic_skills import (
@contextmanager
-def create_skill_zip(skill_name: str):
+def create_skill_zip(skill_name: str, unique_suffix: Optional[str] = None):
"""
Helper context manager to create a zip file for a skill.
-
+
Args:
skill_name: Name of the skill directory in test_skills_data/
-
+ unique_suffix: Optional suffix to make the skill name unique in the zip.
+ When provided, the SKILL.md frontmatter name is rewritten
+ to avoid duplicate-name conflicts on the API side.
+
Yields:
File handle to the zip file
-
+
The zip file is automatically cleaned up after use.
"""
+ import time
+
test_dir = Path(__file__).parent / "test_skills_data"
skill_dir = test_dir / skill_name
-
+
# Create a zip file containing the skill directory
+ # When unique_suffix is set, folder name must match skill name in SKILL.md (Anthropic requirement)
+ zip_folder_name = f"{skill_name}-{unique_suffix}" if unique_suffix else skill_name
zip_path = test_dir / f"{skill_name}.zip"
- with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zip_file:
- zip_file.write(skill_dir, arcname=skill_name)
- zip_file.write(skill_dir / "SKILL.md", arcname=f"{skill_name}/SKILL.md")
-
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
+ if unique_suffix is not None:
+ # Rewrite SKILL.md with a unique name and use matching folder name
+ skill_md = (skill_dir / "SKILL.md").read_text()
+ skill_md = skill_md.replace(
+ f"name: {skill_name}",
+ f"name: {zip_folder_name}",
+ )
+ zf.writestr(f"{zip_folder_name}/SKILL.md", skill_md)
+ # Add any other files in the skill dir (e.g. subdirs) under the new folder name
+ for f in skill_dir.rglob("*"):
+ if f.is_file() and f.name != "SKILL.md":
+ rel = f.relative_to(skill_dir)
+ zf.write(f, arcname=f"{zip_folder_name}/{rel}")
+ else:
+ zf.write(skill_dir, arcname=skill_name)
+ zf.write(skill_dir / "SKILL.md", arcname=f"{skill_name}/SKILL.md")
+
try:
with open(zip_path, "rb") as f:
yield f
@@ -77,13 +98,13 @@ class BaseSkillsAPITest(ABC):
def test_create_skill(self):
"""
Test creating a skill.
-
+
Note: This test creates a skill but does not clean it up,
as we want to verify it was created successfully.
The test_delete_skill test will handle cleanup.
"""
import time
-
+
custom_llm_provider = self.get_custom_llm_provider()
api_key = self.get_api_key()
api_base = self.get_api_base()
@@ -96,12 +117,14 @@ class BaseSkillsAPITest(ABC):
# Use helper to create skill zip
skill_name = "test-skill-litellm"
-
- # Use unique title to avoid conflicts with previous test runs
- unique_title = f"Test Skill {int(time.time())}"
-
+
+ # Use unique title and unique skill name to avoid conflicts
+ # with previous test runs (skills are never cleaned up in CI)
+ ts = str(int(time.time()))
+ unique_title = f"Test Skill {ts}"
+
# Upload the skill with the zip file
- with create_skill_zip(skill_name) as zip_file:
+ with create_skill_zip(skill_name, unique_suffix=ts) as zip_file:
response = litellm.create_skill(
display_title=unique_title,
files=[zip_file],
@@ -217,12 +240,13 @@ class BaseSkillsAPITest(ABC):
# Use helper to create skill zip
skill_name = "test-delete-skill"
-
- # Use unique title to avoid conflicts
- unique_title = f"Test Delete Skill {int(time.time())}"
-
+
+ # Use unique title and skill name to avoid conflicts
+ ts = str(int(time.time()))
+ unique_title = f"Test Delete Skill {ts}"
+
# Create a skill specifically to delete
- with create_skill_zip(skill_name) as zip_file:
+ with create_skill_zip(skill_name, unique_suffix=ts) as zip_file:
created_skill = litellm.create_skill(
display_title=unique_title,
files=[zip_file],
diff --git a/tests/local_testing/test_custom_callback_input.py b/tests/local_testing/test_custom_callback_input.py
index fcdfcfe6e7..ead387599d 100644
--- a/tests/local_testing/test_custom_callback_input.py
+++ b/tests/local_testing/test_custom_callback_input.py
@@ -1300,9 +1300,11 @@ def test_logging_async_cache_hit_sync_call(turn_off_message_logging):
"redacted-by-litellm"
== standard_logging_object["messages"][0]["content"]
)
- assert {"text": "redacted-by-litellm"} == standard_logging_object[
- "response"
- ]
+ # response is a full ModelResponse dict (choices format) since d84e5e381acf
+ assert (
+ standard_logging_object["response"]["choices"][0]["message"]["content"]
+ == "redacted-by-litellm"
+ )
def test_logging_standard_payload_failure_call():
diff --git a/tests/local_testing/test_stream_chunk_builder.py b/tests/local_testing/test_stream_chunk_builder.py
index e5d909812c..4609b274ec 100644
--- a/tests/local_testing/test_stream_chunk_builder.py
+++ b/tests/local_testing/test_stream_chunk_builder.py
@@ -636,7 +636,7 @@ def test_stream_chunk_builder_openai_prompt_caching():
assert response_usage_value == v
-@pytest.mark.flaky(retries=3, delay=2)
+@pytest.mark.flaky(retries=5, delay=2)
def test_stream_chunk_builder_openai_audio_output_usage():
from pydantic import BaseModel
from openai import OpenAI
@@ -667,13 +667,15 @@ def test_stream_chunk_builder_openai_audio_output_usage():
usage_obj: Optional[litellm.Usage] = None
for index, chunk in enumerate(chunks):
- if hasattr(chunk, "usage"):
+ if hasattr(chunk, "usage") and chunk.usage is not None:
usage_obj = chunk.usage
print(f"chunk usage: {chunk.usage}")
print(f"index: {index}")
print(f"len chunks: {len(chunks)}")
print(f"usage_obj: {usage_obj}")
+ if usage_obj is None:
+ pytest.skip("OpenAI did not return usage data in streaming response")
response = stream_chunk_builder(chunks=chunks)
print(f"response usage: {response.usage}")
check_non_streaming_response(response)
diff --git a/tests/logging_callback_tests/test_logging_redaction_e2e_test.py b/tests/logging_callback_tests/test_logging_redaction_e2e_test.py
index 0536ec7205..0391a5a895 100644
--- a/tests/logging_callback_tests/test_logging_redaction_e2e_test.py
+++ b/tests/logging_callback_tests/test_logging_redaction_e2e_test.py
@@ -45,7 +45,8 @@ async def test_global_redaction_on():
await asyncio.sleep(1)
standard_logging_payload = test_custom_logger.logged_standard_logging_payload
assert standard_logging_payload is not None
- assert standard_logging_payload["response"] == {"text": "redacted-by-litellm"}
+ response = standard_logging_payload["response"]
+ assert response["choices"][0]["message"]["content"] == "redacted-by-litellm"
assert standard_logging_payload["messages"][0]["content"] == "redacted-by-litellm"
print(
"logged standard logging payload",
@@ -75,7 +76,8 @@ async def test_global_redaction_with_dynamic_params(turn_off_message_logging):
)
if turn_off_message_logging is True:
- assert standard_logging_payload["response"] == {"text": "redacted-by-litellm"}
+ response = standard_logging_payload["response"]
+ assert response["choices"][0]["message"]["content"] == "redacted-by-litellm"
assert (
standard_logging_payload["messages"][0]["content"] == "redacted-by-litellm"
)
@@ -108,7 +110,8 @@ async def test_global_redaction_off_with_dynamic_params(turn_off_message_logging
json.dumps(standard_logging_payload, indent=2),
)
if turn_off_message_logging is True:
- assert standard_logging_payload["response"] == {"text": "redacted-by-litellm"}
+ response = standard_logging_payload["response"]
+ assert response["choices"][0]["message"]["content"] == "redacted-by-litellm"
assert (
standard_logging_payload["messages"][0]["content"] == "redacted-by-litellm"
)
@@ -390,7 +393,8 @@ async def test_redaction_with_streaming_response():
assert standard_logging_payload is not None
# Verify that redaction worked without pickle errors
- assert standard_logging_payload["response"] == {"text": "redacted-by-litellm"}
+ response = standard_logging_payload["response"]
+ assert response["choices"][0]["message"]["content"] == "redacted-by-litellm"
assert standard_logging_payload["messages"][0]["content"] == "redacted-by-litellm"
print(
"logged standard logging payload for streaming with coroutine handling",
@@ -477,5 +481,6 @@ async def test_redaction_with_metadata_completion_api():
# Verify the helper function works correctly - with get_metadata_variable_name_from_kwargs,
# the system checks the appropriate field for headers
- assert standard_logging_payload["response"] == {"text": "redacted-by-litellm"}
+ response = standard_logging_payload["response"]
+ assert response["choices"][0]["message"]["content"] == "redacted-by-litellm"
assert standard_logging_payload["messages"][0]["content"] == "redacted-by-litellm"
diff --git a/tests/pass_through_tests/test_anthropic_passthrough.py b/tests/pass_through_tests/test_anthropic_passthrough.py
index 5d30338b04..74829a21a0 100644
--- a/tests/pass_through_tests/test_anthropic_passthrough.py
+++ b/tests/pass_through_tests/test_anthropic_passthrough.py
@@ -395,7 +395,7 @@ async def test_anthropic_messages_openai_model_streaming_cost_injection():
payload = {
"model": "openai/gpt-4o",
- "max_tokens": 10,
+ "max_tokens": 20,
"stream": True,
"messages": [{"role": "user", "content": "Say 'Hi'"}],
}
diff --git a/tests/proxy_e2e_anthropic_messages_tests/test_config.yaml b/tests/proxy_e2e_anthropic_messages_tests/test_config.yaml
index 16ee015868..eea8ad6ec1 100644
--- a/tests/proxy_e2e_anthropic_messages_tests/test_config.yaml
+++ b/tests/proxy_e2e_anthropic_messages_tests/test_config.yaml
@@ -53,4 +53,5 @@ general_settings:
forward_client_headers_to_llm_api: true
litellm_settings:
- drop_params: true
\ No newline at end of file
+ drop_params: true
+ modify_params: true
diff --git a/tests/search_tests/test_searxng_search.py b/tests/search_tests/test_searxng_search.py
index 66ebb2a37d..8a8ac1405d 100644
--- a/tests/search_tests/test_searxng_search.py
+++ b/tests/search_tests/test_searxng_search.py
@@ -1,110 +1,327 @@
-import pytest
-import litellm
+"""
+Unit tests for SearXNG Search request/response transformation.
+
+These tests validate the request payload and response parsing without
+requiring a live SearXNG instance.
+"""
+
+import json
import os
-from typing import List, Union
+from unittest.mock import MagicMock, patch
+from urllib.parse import parse_qs, urlparse
-from tests.search_tests.base_search_unit_tests import BaseSearchTest
+import httpx
+import pytest
+
+from litellm.llms.searxng.search.transformation import SearXNGSearchConfig
-class TestSearXNGSearch(BaseSearchTest):
+class TestSearXNGSearchRequestTransformation:
"""
- Tests for SearXNG Search functionality.
+ Tests that SearXNG search requests are transformed into the expected payload.
"""
-
- def get_search_provider(self) -> str:
- """
- Return search_provider for SearXNG Search.
- """
- return "searxng"
-
- @pytest.mark.asyncio
- async def test_basic_search(self):
- """
- Test basic search functionality with a simple query.
- Override to handle free (0.0 cost) provider.
- """
- os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
- litellm.model_cost = litellm.get_model_cost_map(url="")
- litellm._turn_on_debug()
- search_provider = self.get_search_provider()
- print("Search Provider=", search_provider)
- try:
- response = await litellm.asearch(
- query="latest developments in AI",
- search_provider=search_provider,
- )
- print("Search response=", response.model_dump_json(indent=4))
+ def setup_method(self):
+ self.config = SearXNGSearchConfig()
- print(f"\n{'='*80}")
- print(f"Response type: {type(response)}")
- print(f"Response object: {response.object if hasattr(response, 'object') else 'N/A'}")
-
- # Check if response has expected Search format
- assert hasattr(response, "results"), "Response should have 'results' attribute"
- assert hasattr(response, "object"), "Response should have 'object' attribute"
- assert response.object == "search", f"Expected object='search', got '{response.object}'"
-
- # Validate results structure
- assert isinstance(response.results, list), "results should be a list"
- assert len(response.results) > 0, "Should have at least one result"
-
- # Check first result structure
- first_result = response.results[0]
- assert hasattr(first_result, "title"), "Result should have 'title' attribute"
- assert hasattr(first_result, "url"), "Result should have 'url' attribute"
- assert hasattr(first_result, "snippet"), "Result should have 'snippet' attribute"
-
- print(f"Total results: {len(response.results)}")
- print(f"First result title: {first_result.title}")
- print(f"First result URL: {first_result.url}")
- print(f"First result snippet: {first_result.snippet[:100]}...")
- print(f"{'='*80}\n")
-
- assert len(first_result.title) > 0, "Title should not be empty"
- assert len(first_result.url) > 0, "URL should not be empty"
- assert len(first_result.snippet) > 0, "Snippet should not be empty"
-
- # Validate cost tracking in _hidden_params
- # For SearXNG (free provider), cost can be None or 0.0
- assert hasattr(response, "_hidden_params"), "Response should have '_hidden_params' attribute"
- hidden_params = response._hidden_params
- assert "response_cost" in hidden_params, "_hidden_params should contain 'response_cost'"
-
- response_cost = hidden_params["response_cost"]
- # SearXNG is free, so cost can be None or 0.0
- if response_cost is not None:
- assert isinstance(response_cost, (int, float)), "response_cost should be a number"
- assert response_cost >= 0, "response_cost should be non-negative"
- print(f"Cost tracking: ${response_cost:.6f}")
- else:
- print(f"Cost tracking: Free (None)")
-
- except Exception as e:
- pytest.fail(f"Search call failed: {str(e)}")
-
- @pytest.mark.flaky(retries=3, delay=5)
- def test_search_with_optional_params(self):
- """
- Test search with optional parameters.
- Override for SearXNG since it doesn't natively limit results.
- """
- litellm.set_verbose = True
- search_provider = self.get_search_provider()
-
- response = litellm.search(
- query="machine learning",
- search_provider=search_provider,
- max_results=5,
+ def test_basic_query_request(self):
+ """Test that a basic query produces the expected SearXNG request params."""
+ result = self.config.transform_search_request(
+ query="artificial intelligence recent news",
+ optional_params={},
)
- # Validate response
- assert hasattr(response, "results"), "Response should have 'results' attribute"
- assert isinstance(response.results, list), "results should be a list"
- assert len(response.results) > 0, "Should have at least one result"
- # Note: SearXNG doesn't natively limit results, so we don't check <= 5
-
- print(f"\nSearch with optional params validated:")
- print(f" - Requested max_results: 5")
- print(f" - Received results: {len(response.results)}")
+ assert "_searxng_params" in result
+ params = result["_searxng_params"]
+ assert params["q"] == "artificial intelligence recent news"
+ assert params["format"] == "json"
+ def test_list_query_joined(self):
+ """Test that a list query is joined into a single string."""
+ result = self.config.transform_search_request(
+ query=["artificial intelligence", "recent news"],
+ optional_params={},
+ )
+
+ params = result["_searxng_params"]
+ assert params["q"] == "artificial intelligence recent news"
+ assert params["format"] == "json"
+
+ def test_country_to_language_mapping(self):
+ """Test that country codes are mapped to SearXNG language params."""
+ test_cases = {
+ "us": "en",
+ "uk": "en",
+ "de": "de",
+ "fr": "fr",
+ "es": "es",
+ "jp": "ja",
+ "br": "br", # unmapped country passed through as-is
+ }
+ for country, expected_language in test_cases.items():
+ result = self.config.transform_search_request(
+ query="test",
+ optional_params={"country": country},
+ )
+ params = result["_searxng_params"]
+ assert params["language"] == expected_language, (
+ f"country={country} should map to language={expected_language}"
+ )
+
+ def test_max_results_ignored(self):
+ """Test that max_results is accepted but doesn't add extra params."""
+ result = self.config.transform_search_request(
+ query="test",
+ optional_params={"max_results": 5},
+ )
+
+ params = result["_searxng_params"]
+ assert params["q"] == "test"
+ assert params["format"] == "json"
+ # max_results should not appear in the SearXNG params
+ assert "max_results" not in params
+
+ def test_searxng_specific_params_passthrough(self):
+ """Test that SearXNG-specific params are passed through as-is."""
+ result = self.config.transform_search_request(
+ query="test",
+ optional_params={"categories": "general,news", "engines": "google,bing", "time_range": "month"},
+ )
+
+ params = result["_searxng_params"]
+ assert params["q"] == "test"
+ assert params["format"] == "json"
+ assert params["categories"] == "general,news"
+ assert params["engines"] == "google,bing"
+ assert params["time_range"] == "month"
+
+
+class TestSearXNGSearchURLConstruction:
+ """
+ Tests that the complete URL is built correctly from api_base and request params.
+ """
+
+ def setup_method(self):
+ self.config = SearXNGSearchConfig()
+
+ def test_url_with_search_suffix(self):
+ """Test URL construction appends /search."""
+ data = {"_searxng_params": {"q": "test query", "format": "json"}}
+ url = self.config.get_complete_url(
+ api_base="https://searxng.example.com",
+ optional_params={},
+ data=data,
+ )
+
+ parsed = urlparse(url)
+ assert parsed.scheme == "https"
+ assert parsed.netloc == "searxng.example.com"
+ assert parsed.path == "/search"
+ query_params = parse_qs(parsed.query)
+ assert query_params["q"] == ["test query"]
+ assert query_params["format"] == ["json"]
+
+ def test_url_already_has_search_suffix(self):
+ """Test URL construction doesn't double-append /search."""
+ data = {"_searxng_params": {"q": "test", "format": "json"}}
+ url = self.config.get_complete_url(
+ api_base="https://searxng.example.com/search",
+ optional_params={},
+ data=data,
+ )
+
+ parsed = urlparse(url)
+ assert parsed.path == "/search"
+ assert "/search/search" not in url
+
+ def test_url_with_trailing_slash(self):
+ """Test URL construction with trailing slash on api_base."""
+ data = {"_searxng_params": {"q": "test", "format": "json"}}
+ url = self.config.get_complete_url(
+ api_base="https://searxng.example.com/",
+ optional_params={},
+ data=data,
+ )
+
+ parsed = urlparse(url)
+ assert parsed.path == "/search"
+
+ def test_url_from_env_variable(self):
+ """Test URL construction falls back to SEARXNG_API_BASE env var."""
+ data = {"_searxng_params": {"q": "test", "format": "json"}}
+ with patch(
+ "litellm.llms.searxng.search.transformation.get_secret_str",
+ return_value="https://env-searxng.example.com",
+ ):
+ url = self.config.get_complete_url(
+ api_base=None,
+ optional_params={},
+ data=data,
+ )
+
+ assert url.startswith("https://env-searxng.example.com/search?")
+
+ def test_url_missing_api_base_raises(self):
+ """Test that missing api_base and env var raises ValueError."""
+ with patch(
+ "litellm.llms.searxng.search.transformation.get_secret_str",
+ return_value=None,
+ ):
+ with pytest.raises(ValueError, match="SEARXNG_API_BASE is not set"):
+ self.config.get_complete_url(
+ api_base=None,
+ optional_params={},
+ data={"_searxng_params": {"q": "test"}},
+ )
+
+ def test_url_without_data_returns_base(self):
+ """Test URL construction without data returns just the api_base/search."""
+ url = self.config.get_complete_url(
+ api_base="https://searxng.example.com",
+ optional_params={},
+ data=None,
+ )
+
+ assert url == "https://searxng.example.com/search"
+
+
+class TestSearXNGSearchResponseTransformation:
+ """
+ Tests that SearXNG API responses are correctly transformed to SearchResponse.
+ """
+
+ def setup_method(self):
+ self.config = SearXNGSearchConfig()
+ self.logging_obj = MagicMock()
+
+ def _make_mock_response(self, json_data: dict) -> httpx.Response:
+ response = httpx.Response(
+ status_code=200,
+ json=json_data,
+ request=httpx.Request("GET", "https://searxng.example.com/search"),
+ )
+ return response
+
+ def test_response_with_results(self):
+ """Test transforming a typical SearXNG response with results."""
+ raw = self._make_mock_response({
+ "results": [
+ {
+ "title": "AI News Article",
+ "url": "https://example.com/ai-news",
+ "content": "Latest developments in artificial intelligence.",
+ "publishedDate": "2025-01-15",
+ },
+ {
+ "title": "ML Research Paper",
+ "url": "https://example.com/ml-paper",
+ "content": "New machine learning research findings.",
+ "pubdate": "2025-01-10",
+ },
+ ]
+ })
+
+ response = self.config.transform_search_response(
+ raw_response=raw, logging_obj=self.logging_obj
+ )
+
+ assert response.object == "search"
+ assert len(response.results) == 2
+
+ first = response.results[0]
+ assert first.title == "AI News Article"
+ assert first.url == "https://example.com/ai-news"
+ assert first.snippet == "Latest developments in artificial intelligence."
+ assert first.date == "2025-01-15"
+ assert first.last_updated is None
+
+ second = response.results[1]
+ assert second.title == "ML Research Paper"
+ assert second.date == "2025-01-10" # from pubdate field
+
+ def test_response_empty_results(self):
+ """Test transforming a response with no results."""
+ raw = self._make_mock_response({"results": []})
+
+ response = self.config.transform_search_response(
+ raw_response=raw, logging_obj=self.logging_obj
+ )
+
+ assert response.object == "search"
+ assert response.results == []
+
+ def test_response_missing_results_key(self):
+ """Test transforming a response that has no 'results' key."""
+ raw = self._make_mock_response({"query": "test"})
+
+ response = self.config.transform_search_response(
+ raw_response=raw, logging_obj=self.logging_obj
+ )
+
+ assert response.object == "search"
+ assert response.results == []
+
+ def test_response_missing_optional_fields(self):
+ """Test transforming results with missing optional fields."""
+ raw = self._make_mock_response({
+ "results": [
+ {
+ "title": "Minimal Result",
+ "url": "https://example.com",
+ }
+ ]
+ })
+
+ response = self.config.transform_search_response(
+ raw_response=raw, logging_obj=self.logging_obj
+ )
+
+ result = response.results[0]
+ assert result.title == "Minimal Result"
+ assert result.url == "https://example.com"
+ assert result.snippet == "" # defaults to empty string
+ assert result.date is None
+ assert result.last_updated is None
+
+
+class TestSearXNGSearchHeaders:
+ """
+ Tests for header/environment validation.
+ """
+
+ def setup_method(self):
+ self.config = SearXNGSearchConfig()
+
+ def test_headers_without_api_key(self):
+ """Test that headers are set correctly without an API key."""
+ with patch(
+ "litellm.llms.searxng.search.transformation.get_secret_str",
+ return_value=None,
+ ):
+ headers = self.config.validate_environment(headers={})
+
+ assert headers["Content-Type"] == "application/json"
+ assert "Authorization" not in headers
+
+ def test_headers_with_api_key(self):
+ """Test that headers include Authorization when API key is provided."""
+ headers = self.config.validate_environment(
+ headers={}, api_key="test-key-123"
+ )
+
+ assert headers["Content-Type"] == "application/json"
+ assert headers["Authorization"] == "Bearer test-key-123"
+
+ def test_headers_with_env_api_key(self):
+ """Test that headers use SEARXNG_API_KEY from env."""
+ with patch(
+ "litellm.llms.searxng.search.transformation.get_secret_str",
+ return_value="env-key-456",
+ ):
+ headers = self.config.validate_environment(headers={})
+
+ assert headers["Authorization"] == "Bearer env-key-456"
+
+ def test_http_method_is_get(self):
+ """Test that the HTTP method is GET."""
+ assert self.config.get_http_method() == "GET"
diff --git a/tests/search_tests/test_serper_search.py b/tests/search_tests/test_serper_search.py
new file mode 100644
index 0000000000..fbc1b132ee
--- /dev/null
+++ b/tests/search_tests/test_serper_search.py
@@ -0,0 +1,184 @@
+"""
+Tests for Serper Search API integration.
+"""
+import os
+import sys
+import pytest
+from unittest.mock import AsyncMock, patch, MagicMock
+
+sys.path.insert(
+ 0, os.path.abspath("../..")
+)
+
+import litellm
+
+
+class TestSerperSearch:
+ """
+ Tests for Serper Search functionality with mocked network responses.
+ """
+
+ @pytest.mark.asyncio
+ async def test_serper_search_request_payload(self):
+ """
+ Test that validates the Serper search request payload structure without making real API calls.
+ """
+ # Set environment variable for API key
+ os.environ["SERPER_API_KEY"] = "test-api-key"
+
+ # Create a mock response
+ mock_response = MagicMock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = {
+ "organic": [
+ {
+ "title": "Test Result 1",
+ "link": "https://example.com/1",
+ "snippet": "This is a test snippet for result 1",
+ "position": 1,
+ },
+ {
+ "title": "Test Result 2",
+ "link": "https://example.com/2",
+ "snippet": "This is a test snippet for result 2",
+ "position": 2,
+ "date": "Jan 15, 2025",
+ },
+ ],
+ }
+
+ # Mock the httpx AsyncClient post method
+ with patch("litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post", new_callable=AsyncMock) as mock_post:
+ mock_post.return_value = mock_response
+
+ # Make the search call
+ response = await litellm.asearch(
+ query="latest developments in AI",
+ search_provider="serper",
+ max_results=5
+ )
+
+ # Verify the post method was called once
+ assert mock_post.call_count == 1
+
+ # Get the actual call arguments
+ call_args = mock_post.call_args
+
+ # Verify URL
+ assert call_args.kwargs["url"] == "https://google.serper.dev/search"
+
+ # Verify headers contain X-API-KEY
+ headers = call_args.kwargs.get("headers", {})
+ assert "X-API-KEY" in headers
+ assert headers["X-API-KEY"] == "test-api-key"
+ assert headers["Content-Type"] == "application/json"
+
+ # Verify request payload
+ json_data = call_args.kwargs.get("json")
+ assert json_data is not None
+ assert json_data["q"] == "latest developments in AI"
+ assert json_data["num"] == 5
+
+ # Verify response structure
+ assert hasattr(response, "results")
+ assert hasattr(response, "object")
+ assert response.object == "search"
+ assert len(response.results) == 2
+
+ # Verify first result
+ first_result = response.results[0]
+ assert first_result.title == "Test Result 1"
+ assert first_result.url == "https://example.com/1"
+ assert first_result.snippet == "This is a test snippet for result 1"
+
+ # Verify date on second result
+ second_result = response.results[1]
+ assert second_result.date == "Jan 15, 2025"
+
+ @pytest.mark.asyncio
+ async def test_serper_search_with_country(self):
+ """
+ Test that country parameter is mapped to 'gl' in Serper request.
+ """
+ os.environ["SERPER_API_KEY"] = "test-api-key"
+
+ mock_response = MagicMock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = {
+ "organic": [
+ {
+ "title": "Result",
+ "link": "https://example.com",
+ "snippet": "Snippet",
+ }
+ ]
+ }
+
+ with patch("litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post", new_callable=AsyncMock) as mock_post:
+ mock_post.return_value = mock_response
+
+ await litellm.asearch(
+ query="test query",
+ search_provider="serper",
+ country="US",
+ )
+
+ json_data = mock_post.call_args.kwargs.get("json")
+ assert json_data["gl"] == "us"
+
+ @pytest.mark.asyncio
+ async def test_serper_search_with_domain_filter(self):
+ """
+ Test that search_domain_filter is appended as site: clauses to the query.
+ """
+ os.environ["SERPER_API_KEY"] = "test-api-key"
+
+ mock_response = MagicMock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = {
+ "organic": [
+ {
+ "title": "Result",
+ "link": "https://arxiv.org/paper/1",
+ "snippet": "Snippet",
+ }
+ ]
+ }
+
+ with patch("litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post", new_callable=AsyncMock) as mock_post:
+ mock_post.return_value = mock_response
+
+ await litellm.asearch(
+ query="machine learning",
+ search_provider="serper",
+ search_domain_filter=["arxiv.org", "nature.com"],
+ )
+
+ json_data = mock_post.call_args.kwargs.get("json")
+ assert "site:arxiv.org" in json_data["q"]
+ assert "site:nature.com" in json_data["q"]
+ assert "machine learning" in json_data["q"]
+
+ @pytest.mark.asyncio
+ async def test_serper_search_empty_organic(self):
+ """
+ Test handling of response with no organic results.
+ """
+ os.environ["SERPER_API_KEY"] = "test-api-key"
+
+ mock_response = MagicMock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = {
+ "searchParameters": {"q": "xyznonexistent"},
+ }
+
+ with patch("litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post", new_callable=AsyncMock) as mock_post:
+ mock_post.return_value = mock_response
+
+ response = await litellm.asearch(
+ query="xyznonexistent",
+ search_provider="serper",
+ )
+
+ assert response.object == "search"
+ assert len(response.results) == 0
diff --git a/tests/test_litellm/llms/bedrock/chat/agentcore/test_agentcore_transformation.py b/tests/test_litellm/llms/bedrock/chat/agentcore/test_agentcore_transformation.py
index b122a08371..acb55a9739 100644
--- a/tests/test_litellm/llms/bedrock/chat/agentcore/test_agentcore_transformation.py
+++ b/tests/test_litellm/llms/bedrock/chat/agentcore/test_agentcore_transformation.py
@@ -1,20 +1,22 @@
"""
-Unit tests for Bedrock AgentCore transformation — Accept header fix.
+Unit tests for Bedrock AgentCore transformation.
-Verifies that AmazonAgentCoreConfig.sign_request() sets the
-Accept: application/json, text/event-stream header required by
-MCP servers on Bedrock AgentCore.
+Tests:
+- Accept header fix (sign_request sets Accept: application/json, text/event-stream)
+- JSON response parsing fallback chain (_parse_json_response supports multiple schemas)
+- Streaming Content-Type fallback (JSON responses converted to single-chunk streams)
"""
import json
import os
import sys
+import httpx
import pytest
sys.path.insert(0, os.path.abspath("../../../../../.."))
-from unittest.mock import MagicMock, patch
+from unittest.mock import MagicMock, Mock, patch
import litellm
from litellm.llms.bedrock.chat.agentcore.transformation import AmazonAgentCoreConfig
@@ -81,3 +83,237 @@ class TestAgentCoreAcceptHeader:
headers = mock_post.call_args.kwargs["headers"]
assert "Accept" in headers
assert headers["Accept"] == "application/json, text/event-stream"
+
+
+class TestAgentCoreJsonResponseParsing:
+ """Tests for _parse_json_response fallback chain."""
+
+ @pytest.fixture
+ def config(self):
+ return AmazonAgentCoreConfig()
+
+ def test_parse_json_standard_agentcore_format(self, config):
+ """Strategy 1: standard {"result": {"content": [{"text": "..."}]}} format."""
+ response_json = {
+ "result": {
+ "role": "assistant",
+ "content": [{"text": "Hello from standard format"}],
+ }
+ }
+ parsed = config._parse_json_response(response_json)
+ assert parsed["content"] == "Hello from standard format"
+ assert parsed["usage"] is None
+ assert parsed["final_message"] == response_json["result"]
+
+ def test_parse_json_strands_format(self, config):
+ """Strategy 2: Strands {"response": [{"text": "..."}]} format."""
+ response_json = {
+ "response": [
+ {"text": "Based on my research, "},
+ {"text": "iOS 18.2 was released."},
+ ]
+ }
+ parsed = config._parse_json_response(response_json)
+ assert parsed["content"] == "Based on my research, iOS 18.2 was released."
+ assert parsed["usage"] is None
+ assert parsed["final_message"] is None
+
+ def test_parse_json_string_result(self, config):
+ """Strategy 3: plain string {"result": "text"} format."""
+ response_json = {"result": "Simple text response"}
+ parsed = config._parse_json_response(response_json)
+ assert parsed["content"] == "Simple text response"
+ assert parsed["usage"] is None
+
+ def test_parse_json_string_response(self, config):
+ """Strategy 3: plain string {"response": "text"} format."""
+ response_json = {"response": "Another text response"}
+ parsed = config._parse_json_response(response_json)
+ assert parsed["content"] == "Another text response"
+ assert parsed["usage"] is None
+
+ def test_parse_json_unknown_format_fallback(self, config):
+ """Strategy 4: unknown keys fall back to raw JSON."""
+ response_json = {"custom_key": "custom_value", "data": [1, 2, 3]}
+ parsed = config._parse_json_response(response_json)
+ assert parsed["content"] == json.dumps(response_json)
+ assert parsed["usage"] is None
+ assert parsed["final_message"] is None
+
+ def test_parse_json_non_dict_response(self, config):
+ """Guard: non-dict JSON (e.g. array) falls back to raw JSON string."""
+ response_json = [{"text": "array response"}]
+ parsed = config._parse_json_response(response_json)
+ assert parsed["content"] == json.dumps(response_json)
+ assert parsed["usage"] is None
+ assert parsed["final_message"] is None
+
+ def test_parse_json_empty_content_in_result(self, config):
+ """Standard format with empty content list - preserves existing behavior."""
+ response_json = {
+ "result": {
+ "role": "assistant",
+ "content": [],
+ }
+ }
+ parsed = config._parse_json_response(response_json)
+ assert parsed["content"] == ""
+ assert parsed["final_message"] == response_json["result"]
+
+
+class TestAgentCoreNonStreamingJsonFormats:
+ """Tests for _get_parsed_response with different JSON formats (non-streaming path)."""
+
+ @pytest.fixture
+ def config(self):
+ return AmazonAgentCoreConfig()
+
+ def test_get_parsed_response_strands_json(self, config):
+ """
+ Non-streaming path: _get_parsed_response routes application/json
+ to _parse_json_response which handles the Strands format.
+ """
+ mock_response = Mock(spec=httpx.Response)
+ mock_response.headers = {"content-type": "application/json"}
+ mock_response.json.return_value = {
+ "response": [{"text": "Strands agent response via non-streaming"}]
+ }
+ parsed = config._get_parsed_response(mock_response)
+ assert parsed["content"] == "Strands agent response via non-streaming"
+ assert parsed["usage"] is None
+
+ def test_get_parsed_response_raw_json_fallback(self, config):
+ """
+ Non-streaming path: unknown JSON schema falls back to raw JSON string.
+ """
+ response_json = {"output": "some value"}
+ mock_response = Mock(spec=httpx.Response)
+ mock_response.headers = {"content-type": "application/json"}
+ mock_response.json.return_value = response_json
+ parsed = config._get_parsed_response(mock_response)
+ assert parsed["content"] == json.dumps(response_json)
+
+
+class TestAgentCoreStreamingJsonFallback:
+ """Tests for streaming Content-Type check (JSON -> single-chunk stream)."""
+
+ def test_sync_streaming_with_json_response(self):
+ """
+ When stream=True but the agent returns Content-Type: application/json,
+ content is extracted and returned instead of silently returning empty.
+ Exercises the full path through litellm.completion().
+ """
+ from litellm.llms.custom_httpx.http_handler import HTTPHandler
+
+ client = HTTPHandler()
+ json_body = {"response": [{"text": "Strands sync response"}]}
+
+ mock_response = Mock(spec=httpx.Response)
+ mock_response.status_code = 200
+ mock_response.headers = {"content-type": "application/json"}
+ mock_response.read.return_value = json.dumps(json_body).encode()
+
+ with patch.object(client, "post", return_value=mock_response):
+ response = litellm.completion(
+ model="bedrock/agentcore/arn:aws:bedrock-agentcore:us-west-2:888602223428:runtime/test_agent",
+ messages=[{"role": "user", "content": "test"}],
+ stream=True,
+ client=client,
+ )
+
+ # Collect content across all chunks
+ # CustomStreamWrapper yields content chunk(s) + a synthetic stop chunk
+ content = ""
+ for chunk in response:
+ if chunk.choices[0].delta.content:
+ content += chunk.choices[0].delta.content
+
+ assert content == "Strands sync response"
+
+ async def test_async_streaming_with_json_response(self):
+ """
+ Async streaming: same Content-Type: application/json fallback via
+ litellm.acompletion(stream=True).
+ """
+ from unittest.mock import AsyncMock
+
+ from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
+
+ client = AsyncHTTPHandler()
+ json_body = {"response": [{"text": "Strands async response"}]}
+
+ mock_response = Mock(spec=httpx.Response)
+ mock_response.status_code = 200
+ mock_response.headers = {"content-type": "application/json"}
+ mock_response.aread = AsyncMock(
+ return_value=json.dumps(json_body).encode()
+ )
+
+ with patch.object(
+ client, "post", new_callable=AsyncMock, return_value=mock_response
+ ):
+ response = await litellm.acompletion(
+ model="bedrock/agentcore/arn:aws:bedrock-agentcore:us-west-2:888602223428:runtime/test_agent",
+ messages=[{"role": "user", "content": "test"}],
+ stream=True,
+ client=client,
+ )
+
+ # Collect content across all chunks
+ content = ""
+ async for chunk in response:
+ if chunk.choices[0].delta.content:
+ content += chunk.choices[0].delta.content
+
+ assert content == "Strands async response"
+
+ def test_sync_streaming_malformed_json_raises_error(self):
+ """
+ When stream=True and Content-Type is application/json but the body
+ is malformed JSON, an error is raised with a descriptive message
+ (not a raw JSONDecodeError).
+ """
+ from litellm.llms.custom_httpx.http_handler import HTTPHandler
+
+ client = HTTPHandler()
+
+ mock_response = Mock(spec=httpx.Response)
+ mock_response.status_code = 200
+ mock_response.headers = {"content-type": "application/json"}
+ mock_response.read.return_value = b"not valid json {{"
+
+ with patch.object(client, "post", return_value=mock_response):
+ with pytest.raises(Exception, match="Failed to read/parse JSON response body"):
+ litellm.completion(
+ model="bedrock/agentcore/arn:aws:bedrock-agentcore:us-west-2:888602223428:runtime/test_agent",
+ messages=[{"role": "user", "content": "test"}],
+ stream=True,
+ client=client,
+ )
+
+ async def test_async_streaming_malformed_json_raises_error(self):
+ """
+ Async mirror: malformed JSON body raises a structured error, not a
+ raw JSONDecodeError.
+ """
+ from unittest.mock import AsyncMock
+
+ from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
+
+ client = AsyncHTTPHandler()
+
+ mock_response = Mock(spec=httpx.Response)
+ mock_response.status_code = 200
+ mock_response.headers = {"content-type": "application/json"}
+ mock_response.aread = AsyncMock(return_value=b"not valid json {{")
+
+ with patch.object(
+ client, "post", new_callable=AsyncMock, return_value=mock_response
+ ):
+ with pytest.raises(Exception, match="Failed to read/parse JSON response body"):
+ await litellm.acompletion(
+ model="bedrock/agentcore/arn:aws:bedrock-agentcore:us-west-2:888602223428:runtime/test_agent",
+ messages=[{"role": "user", "content": "test"}],
+ stream=True,
+ client=client,
+ )
diff --git a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py
index 345f3ae7c5..7e1f235c49 100644
--- a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py
+++ b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py
@@ -3170,6 +3170,33 @@ def test_transform_request_with_output_config():
assert result["outputConfig"]["textFormat"]["structure"]["jsonSchema"]["name"] == "TestSchema"
+def test_output_config_snake_case_stripped_from_bedrock_converse_request():
+ """Test that output_config (snake_case) is stripped from Bedrock Converse requests.
+
+ Bedrock Converse API doesn't support the output_config parameter (Anthropic-only).
+ Nova and other Converse models reject requests with extraneous output_config.
+ """
+ config = AmazonConverseConfig()
+ messages = [{"role": "user", "content": "test"}]
+ optional_params = {
+ "output_config": {"effort": "high"},
+ }
+
+ result = config._transform_request(
+ model="us.amazon.nova-pro-v1:0",
+ messages=messages,
+ optional_params=optional_params,
+ litellm_params={},
+ headers={},
+ )
+
+ # output_config must not appear in additionalModelRequestFields
+ additional = result.get("additionalModelRequestFields", {})
+ assert "output_config" not in additional, (
+ f"output_config should be stripped for Bedrock Converse, got: {list(additional.keys())}"
+ )
+
+
def test_transform_response_native_structured_output():
"""Test response handling when model returns JSON as text content (native structured output)."""
response_json = {
diff --git a/tests/test_litellm/llms/openai_like/test_charity_engine.py b/tests/test_litellm/llms/openai_like/test_charity_engine.py
new file mode 100644
index 0000000000..5d6a751b62
--- /dev/null
+++ b/tests/test_litellm/llms/openai_like/test_charity_engine.py
@@ -0,0 +1,101 @@
+"""
+Tests for Charity Engine provider configuration and integration.
+"""
+
+import os
+import sys
+
+try:
+ import pytest
+except ImportError:
+ pytest = None
+
+# Add workspace to path
+workspace_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../.."))
+sys.path.insert(0, workspace_path)
+
+import litellm
+
+
+class TestCharityEngineProviderConfig:
+ """Test Charity Engine provider configuration"""
+
+ def test_charity_engine_in_provider_list(self):
+ """Test that charity_engine is in the provider list"""
+ from litellm import LlmProviders
+
+ assert hasattr(LlmProviders, "CHARITY_ENGINE")
+ assert LlmProviders.CHARITY_ENGINE.value == "charity_engine"
+ assert "charity_engine" in litellm.provider_list
+
+ def test_charity_engine_json_config_exists(self):
+ """Test that charity_engine is configured in providers.json"""
+ from litellm.llms.openai_like.json_loader import JSONProviderRegistry
+
+ assert JSONProviderRegistry.exists("charity_engine")
+
+ charity_engine = JSONProviderRegistry.get("charity_engine")
+ assert charity_engine is not None
+ assert charity_engine.base_url == "https://api.charityengine.services/remotejobs/v2/inference"
+ assert charity_engine.api_key_env == "CHARITY_ENGINE_API_KEY"
+ assert charity_engine.param_mappings.get("max_completion_tokens") == "max_tokens"
+
+ def test_charity_engine_provider_resolution(self):
+ """Test that provider resolution finds charity_engine"""
+ from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
+
+ model, provider, api_key, api_base = get_llm_provider(
+ model="charity_engine/gemma3:270m",
+ custom_llm_provider=None,
+ api_base=None,
+ api_key=None,
+ )
+
+ assert model == "gemma3:270m"
+ assert provider == "charity_engine"
+ assert api_base == "https://api.charityengine.services/remotejobs/v2/inference"
+
+ def test_charity_engine_router_config(self):
+ """Test that charity_engine can be used in Router configuration"""
+ from litellm import Router
+
+ router = Router(
+ model_list=[
+ {
+ "model_name": "gemma3-270m",
+ "litellm_params": {
+ "model": "charity_engine/gemma3:270m",
+ "api_key": "test-key",
+ },
+ }
+ ]
+ )
+
+ assert len(router.model_list) == 1
+ assert router.model_list[0]["model_name"] == "gemma3-270m"
+
+
+if __name__ == "__main__":
+ print("Testing Charity Engine Provider...")
+
+ test_config = TestCharityEngineProviderConfig()
+
+ print("\n1. Testing provider in list...")
+ test_config.test_charity_engine_in_provider_list()
+ print(" ✓ charity_engine in provider list")
+
+ print("\n2. Testing JSON config...")
+ test_config.test_charity_engine_json_config_exists()
+ print(" ✓ charity_engine JSON config loaded")
+
+ print("\n3. Testing provider resolution...")
+ test_config.test_charity_engine_provider_resolution()
+ print(" ✓ Provider resolution works")
+
+ print("\n4. Testing router configuration...")
+ test_config.test_charity_engine_router_config()
+ print(" ✓ Router configuration works")
+
+ print("\n" + "=" * 50)
+ print("✓ All configuration tests passed!")
+ print("=" * 50)
diff --git a/tests/test_litellm/llms/watsonx/test_watsonx.py b/tests/test_litellm/llms/watsonx/test_watsonx.py
index 17c006f554..5ba1276e5e 100644
--- a/tests/test_litellm/llms/watsonx/test_watsonx.py
+++ b/tests/test_litellm/llms/watsonx/test_watsonx.py
@@ -207,13 +207,11 @@ def test_watsonx_completion_regular_model_includes_model_id(
assert "project_id" in json_data
-@pytest.mark.asyncio
-@pytest.mark.xdist_group("watsonx_heavy")
-async def test_watsonx_gpt_oss_prompt_transformation(monkeypatch): # noqa: PLR0915
+def test_watsonx_gpt_oss_prompt_transformation(monkeypatch):
"""
Test that gpt-oss-120b model transforms messages to proper format instead of simple concatenation.
- This test starts from litellm.acompletion and verifies what gets sent in the final POST request body.
+ This test calls litellm.completion (sync) and verifies what gets sent in the final POST request body.
Input messages should be transformed using the HuggingFace chat template from openai/gpt-oss-120b,
not just concatenated as "You are chatgpt Hi there".
"""
@@ -229,39 +227,12 @@ async def test_watsonx_gpt_oss_prompt_transformation(monkeypatch): # noqa: PLR0
{"role": "user", "content": "Hi there"},
]
- # Mock the HTTP client
- from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
-
- client = AsyncHTTPHandler()
-
- # Mock the token call
- mock_token_response = Mock()
- mock_token_response.json.return_value = {
- "access_token": "mock_access_token",
- "expires_in": 3600,
- }
- mock_token_response.raise_for_status = Mock()
-
- # Mock the completion call
- mock_completion_response = Mock()
- mock_completion_response.status_code = 200
- mock_completion_response.json.return_value = {
- "results": [
- {
- "generated_text": "Hello! How can I help you?",
- "generated_token_count": 10,
- "input_token_count": 5,
- "stop_reason": "stop", # Required field for response transformation
- }
- ],
- "model_id": "openai/gpt-oss-120b",
- }
+ client = HTTPHandler()
# Mock HuggingFace template fetch to make test deterministic and avoid network flakiness.
# The test verifies that prompt transformation occurs (not simple concatenation), not the exact
# HuggingFace template format. Using a mock template that produces the correct format is sufficient.
- from unittest.mock import patch
-
+ #
# Mock template that produces gpt-oss-120b-like format.
# Note: This is a simplified version of the actual template. The real template is more complex
# (adds metadata, handles tools, thinking messages, etc.), but this captures the key aspects:
@@ -277,105 +248,46 @@ async def test_watsonx_gpt_oss_prompt_transformation(monkeypatch): # noqa: PLR0
},
}
- async def mock_aget_tokenizer_config(hf_model_name: str):
- return mock_tokenizer_config
-
- async def mock_aget_chat_template_file(hf_model_name: str):
- # Return failure to use tokenizer_config instead
- return {"status": "failure"}
-
- # Set cached tokenizer config directly to avoid race conditions with parallel tests.
- # When running with pytest-xdist (-n 16), another test might populate the cache between
- # clearing it and the actual usage. By setting the cache directly, we ensure the correct
- # template is always used regardless of test execution order.
+ # Isolate known_tokenizer_config so parallel tests don't interfere.
+ # monkeypatch.setitem restores the original value on teardown.
hf_model = "openai/gpt-oss-120b"
- litellm.known_tokenizer_config[hf_model] = mock_tokenizer_config
+ monkeypatch.setitem(litellm.known_tokenizer_config, hf_model, mock_tokenizer_config)
- # Also create sync mock functions in case the fallback sync path is used
- def mock_get_tokenizer_config(hf_model_name: str):
- return mock_tokenizer_config
-
- def mock_get_chat_template_file(hf_model_name: str):
- return {"status": "failure"}
-
- # Async mock function for client.post to properly handle async method mocking
- async def mock_post_func(*args, **kwargs):
- return mock_completion_response
-
- # Mock the token generation response to avoid actual API call
- mock_token_get_response = Mock()
- mock_token_get_response.json.return_value = {
+ # Mock IAM token generation to avoid real HTTP calls.
+ mock_token_response = Mock()
+ mock_token_response.json.return_value = {
"access_token": "mock_access_token",
"expires_in": 3600,
}
- mock_token_get_response.raise_for_status = Mock()
+ mock_token_response.raise_for_status = Mock()
- # Pre-populate the WatsonX IAM token cache to avoid any HTTP calls for token generation.
- # This prevents parallel test interference with litellm.module_level_client.
- from litellm.llms.watsonx.common_utils import iam_token_cache
- iam_token_cache.set_cache(key="test_api_key", value="mock_access_token", ttl=3600)
-
- with patch.object(client, "post", side_effect=mock_post_func) as mock_post, patch.object(
- litellm.module_level_client, "post", return_value=mock_token_get_response
- ), patch(
- "litellm.litellm_core_utils.prompt_templates.huggingface_template_handler._aget_tokenizer_config",
- side_effect=mock_aget_tokenizer_config,
- ), patch(
- "litellm.litellm_core_utils.prompt_templates.huggingface_template_handler._aget_chat_template_file",
- side_effect=mock_aget_chat_template_file,
- ), patch(
- "litellm.litellm_core_utils.prompt_templates.huggingface_template_handler._get_tokenizer_config",
- side_effect=mock_get_tokenizer_config,
- ), patch(
- "litellm.litellm_core_utils.prompt_templates.huggingface_template_handler._get_chat_template_file",
- side_effect=mock_get_chat_template_file,
+ with patch.object(client, "post") as mock_post, patch.object(
+ litellm.module_level_client, "post", return_value=mock_token_response
):
try:
- # Call acompletion with messages
- await litellm.acompletion(
+ completion(
model=model,
messages=messages,
api_key="test_api_key",
client=client,
)
except Exception as e:
- # May fail due to incomplete mocking, but we should have captured the request
- print(f"Exception (may be expected): {e}")
+ print(f"Caught expected exception: {e}")
# Verify the POST was called
assert (
- mock_post.call_count >= 1
- ), f"POST should have been called at least once, got {mock_post.call_count}"
+ mock_post.call_count == 1
+ ), f"POST should have been called exactly once, got {mock_post.call_count}"
- # Get the request body from the first call
- # Use call_args_list to be more robust - get the first call's arguments
- assert len(mock_post.call_args_list) > 0, "mock_post should have at least one call"
- call_args = mock_post.call_args_list[0]
- assert call_args is not None, "call_args should not be None"
+ # Get the request body
+ call_args = mock_post.call_args
assert "data" in call_args.kwargs, "call_args.kwargs should contain 'data'"
json_data = json.loads(call_args.kwargs["data"])
- print(f"\n{'='*80}")
- print(f"Input messages to litellm.acompletion:")
- print(json.dumps(messages, indent=2))
- print(f"\n{'='*80}")
- print(f"Final POST request body:")
- print(json.dumps(json_data, indent=2))
- print(f"{'='*80}\n")
-
# Verify the transformed input is in the request
assert "input" in json_data, "Request should have 'input' field"
transformed_prompt = json_data["input"]
- # Verify transformation occurred
- assert transformed_prompt is not None, (
- "Prompt transformation failed - the template should have been applied to transform "
- "messages into the correct format for gpt-oss-120b."
- )
-
- print(f"Transformed prompt: {repr(transformed_prompt)}")
- print(f"Prompt length: {len(transformed_prompt)}")
-
# Verify it's NOT simple concatenation
simple_concat = "You are chatgpt Hi there"
assert transformed_prompt != simple_concat, (
diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py
index de2ec13b4a..a104ac2257 100644
--- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py
+++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py
@@ -2093,3 +2093,150 @@ async def test_get_tools_from_mcp_servers_logs_list_tools_to_spendlogs_when_enab
assert spend_meta["tool_count_total"] == 1
assert spend_meta["allowed_server_count"] == 1
assert spend_meta["per_server_tool_counts"]["server_a"] == 1
+
+
+def test_tool_name_matches_case_insensitive():
+ """Test that _tool_name_matches performs case-insensitive comparison.
+
+ This is critical for OpenAPI-based MCP servers where:
+ 1. operationIds are often in camelCase (e.g., 'addPet', 'updatePet')
+ 2. Tool names are lowercased during registration (e.g., 'addpet', 'updatepet')
+ 3. allowed_tools configuration may use the original camelCase names
+
+ Without case-insensitive matching, all tools would be filtered out.
+ """
+ try:
+ from litellm.proxy._experimental.mcp_server.server import _tool_name_matches
+ except ImportError:
+ pytest.skip("MCP server not available")
+
+ # Test case 1: Unprefixed tool name with camelCase in filter list
+ assert _tool_name_matches("addpet", ["addPet", "updatePet"]) is True
+ assert _tool_name_matches("updatepet", ["addPet", "updatePet"]) is True
+ assert _tool_name_matches("deletepet", ["addPet", "updatePet"]) is False
+
+ # Test case 2: Prefixed tool name with camelCase in filter list
+ assert _tool_name_matches("per_store-addpet", ["addPet", "updatePet"]) is True
+ assert _tool_name_matches("per_store-updatepet", ["addPet", "updatePet"]) is True
+ assert _tool_name_matches("per_store-deletepet", ["addPet", "updatePet"]) is False
+
+ # Test case 3: Mixed case variations
+ assert _tool_name_matches("findPetsByStatus", ["findpetsbystatus"]) is True
+ assert _tool_name_matches("findpetsbystatus", ["findPetsByStatus"]) is True
+ assert _tool_name_matches("FINDPETSBYSTATUS", ["findPetsByStatus"]) is True
+
+ # Test case 4: Full prefixed name in filter list (case-insensitive)
+ assert _tool_name_matches("server-addPet", ["server-addpet"]) is True
+ assert _tool_name_matches("server-addpet", ["server-addPet"]) is True
+
+ # Test case 5: Ensure non-matching names still don't match
+ assert _tool_name_matches("addpet", ["deletePet", "updatePet"]) is False
+ assert _tool_name_matches("server-addpet", ["deletePet", "updatePet"]) is False
+
+
+def test_filter_tools_by_allowed_tools_case_insensitive():
+ """Test that filter_tools_by_allowed_tools handles case-insensitive matching.
+
+ Ensures that OpenAPI tools with lowercase names can be filtered using
+ camelCase allowed_tools configuration from the OpenAPI spec.
+ """
+ try:
+ from litellm.proxy._experimental.mcp_server.server import (
+ filter_tools_by_allowed_tools,
+ )
+ from litellm.types.mcp_server.tool_registry import MCPTool
+ except ImportError:
+ pytest.skip("MCP server not available")
+
+ # Mock handler function
+ def mock_handler(**kwargs):
+ return kwargs
+
+ # Create mock tools with lowercase names (as registered from OpenAPI)
+ tools = [
+ MCPTool(
+ name="per_store-addpet",
+ description="Add a pet",
+ input_schema={"type": "object"},
+ handler=mock_handler,
+ ),
+ MCPTool(
+ name="per_store-updatepet",
+ description="Update a pet",
+ input_schema={"type": "object"},
+ handler=mock_handler,
+ ),
+ MCPTool(
+ name="per_store-deletepet",
+ description="Delete a pet",
+ input_schema={"type": "object"},
+ handler=mock_handler,
+ ),
+ MCPTool(
+ name="per_store-findpetsbystatus",
+ description="Find pets by status",
+ input_schema={"type": "object"},
+ handler=mock_handler,
+ ),
+ ]
+
+ # Create mock server with camelCase allowed_tools (as from OpenAPI spec)
+ server = MCPServer(
+ server_id="test-server",
+ name="per_store",
+ transport=MCPTransport.http,
+ allowed_tools=["addPet", "updatePet", "findPetsByStatus"],
+ )
+
+ # Filter tools
+ filtered_tools = filter_tools_by_allowed_tools(tools, server)
+
+ # Should return 3 tools (case-insensitive match)
+ assert len(filtered_tools) == 3
+ assert any(t.name == "per_store-addpet" for t in filtered_tools)
+ assert any(t.name == "per_store-updatepet" for t in filtered_tools)
+ assert any(t.name == "per_store-findpetsbystatus" for t in filtered_tools)
+ assert not any(t.name == "per_store-deletepet" for t in filtered_tools)
+
+
+def test_filter_tools_by_allowed_tools_no_filter():
+ """Test that filter_tools_by_allowed_tools returns all tools when no filter is set."""
+ try:
+ from litellm.proxy._experimental.mcp_server.server import (
+ filter_tools_by_allowed_tools,
+ )
+ from litellm.types.mcp_server.tool_registry import MCPTool
+ except ImportError:
+ pytest.skip("MCP server not available")
+
+ # Mock handler function
+ def mock_handler(**kwargs):
+ return kwargs
+
+ tools = [
+ MCPTool(
+ name="fusion_litellm_mcp-model_list",
+ description="List models",
+ input_schema={"type": "object"},
+ handler=mock_handler,
+ ),
+ MCPTool(
+ name="fusion_litellm_mcp-chat_completion",
+ description="Chat completion",
+ input_schema={"type": "object"},
+ handler=mock_handler,
+ ),
+ ]
+
+ # Server with no allowed_tools filter
+ server = MCPServer(
+ server_id="test-server",
+ name="fusion_litellm_mcp",
+ transport=MCPTransport.http,
+ allowed_tools=None,
+ )
+
+ filtered_tools = filter_tools_by_allowed_tools(tools, server)
+
+ # Should return all tools when no filter is configured
+ assert len(filtered_tools) == 2
diff --git a/tests/test_litellm/proxy/agent_endpoints/test_a2a_endpoints.py b/tests/test_litellm/proxy/agent_endpoints/test_a2a_endpoints.py
index bfeabb6f7c..dc6f90b62e 100644
--- a/tests/test_litellm/proxy/agent_endpoints/test_a2a_endpoints.py
+++ b/tests/test_litellm/proxy/agent_endpoints/test_a2a_endpoints.py
@@ -46,6 +46,7 @@ async def test_invoke_agent_a2a_adds_litellm_data():
"url": "http://backend-agent:10001",
"name": "Test Agent",
}
+ mock_agent.litellm_params = None
# Mock request
mock_request = MagicMock()
diff --git a/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py b/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py
index 00d08504fe..3c8e1c7555 100644
--- a/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py
+++ b/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py
@@ -295,6 +295,9 @@ class TestAgentRBACInternalUser:
return_value=_sample_agent_response()
)
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma:
+ mock_prisma.db.litellm_agentstable.find_unique = AsyncMock(
+ return_value=None
+ )
resp = self.internal_client.get(
"/v1/agents/agent-123", headers={"Authorization": "Bearer k"}
)
diff --git a/tests/test_litellm/proxy/auth/test_model_checks.py b/tests/test_litellm/proxy/auth/test_model_checks.py
index 193b014f03..c43621d7f7 100644
--- a/tests/test_litellm/proxy/auth/test_model_checks.py
+++ b/tests/test_litellm/proxy/auth/test_model_checks.py
@@ -21,6 +21,140 @@ def test_get_team_models_for_all_models_and_team_only_models():
assert set(result) == set(combined_models)
+def test_get_team_models_all_proxy_models_includes_access_groups():
+ """
+ When a team has 'all-proxy-models' and include_model_access_groups=True,
+ the result should include model access group names (e.g. 'claude-model-group')
+ in addition to individual model names.
+ """
+ from litellm.proxy.auth.model_checks import get_team_models
+
+ team_models = ["all-proxy-models"]
+ proxy_model_list = ["model1", "model2"]
+ model_access_groups = {
+ "group-a": ["model1"],
+ "group-b": ["model2"],
+ }
+
+ result = get_team_models(
+ team_models, proxy_model_list, model_access_groups, include_model_access_groups=True
+ )
+ assert "group-a" in result
+ assert "group-b" in result
+ assert "model1" in result
+ assert "model2" in result
+ assert len(result) == len(set(result)), "result should have no duplicates"
+
+
+def test_get_team_models_all_proxy_models_without_include_flag():
+ """
+ When include_model_access_groups=False, access group names should NOT
+ appear in the result even with 'all-proxy-models'.
+ """
+ from litellm.proxy.auth.model_checks import get_team_models
+
+ team_models = ["all-proxy-models"]
+ proxy_model_list = ["model1", "model2"]
+ model_access_groups = {
+ "group-a": ["model1"],
+ "group-b": ["model2"],
+ }
+
+ result = get_team_models(
+ team_models, proxy_model_list, model_access_groups, include_model_access_groups=False
+ )
+ assert "group-a" not in result
+ assert "group-b" not in result
+ assert "model1" in result
+ assert "model2" in result
+
+
+def test_get_key_models_all_proxy_models_includes_access_groups():
+ """
+ When a key has 'all-proxy-models' and include_model_access_groups=True,
+ the result should include model access group names.
+ """
+ from litellm.proxy._types import UserAPIKeyAuth
+ from litellm.proxy.auth.model_checks import get_key_models
+
+ user_api_key_dict = UserAPIKeyAuth(
+ models=["all-proxy-models"],
+ api_key="test-key",
+ )
+ proxy_model_list = ["model1", "model2"]
+ model_access_groups = {
+ "group-a": ["model1"],
+ }
+
+ result = get_key_models(
+ user_api_key_dict=user_api_key_dict,
+ proxy_model_list=proxy_model_list,
+ model_access_groups=model_access_groups,
+ include_model_access_groups=True,
+ )
+ assert "group-a" in result
+ assert "model1" in result
+ assert "model2" in result
+ assert len(result) == len(set(result)), "result should have no duplicates"
+
+
+def test_get_key_models_passes_include_model_access_groups():
+ """
+ When a key explicitly has an access group name in its models list and
+ include_model_access_groups=True, the group name should be retained
+ (not stripped by _get_models_from_access_groups).
+ """
+ from litellm.proxy._types import UserAPIKeyAuth
+ from litellm.proxy.auth.model_checks import get_key_models
+
+ user_api_key_dict = UserAPIKeyAuth(
+ models=["group-a"],
+ api_key="test-key",
+ )
+ proxy_model_list = ["model1", "model2"]
+ model_access_groups = {
+ "group-a": ["model1", "model2"],
+ }
+
+ result = get_key_models(
+ user_api_key_dict=user_api_key_dict,
+ proxy_model_list=proxy_model_list,
+ model_access_groups=model_access_groups,
+ include_model_access_groups=True,
+ )
+ assert "group-a" in result
+ assert "model1" in result
+ assert "model2" in result
+
+
+def test_get_key_models_does_not_mutate_input():
+ """
+ get_key_models must not mutate user_api_key_dict.models in-place.
+ _get_models_from_access_groups uses .pop()/.extend() which would corrupt
+ cached UserAPIKeyAuth objects if all_models were an alias instead of a copy.
+ """
+ from litellm.proxy._types import UserAPIKeyAuth
+ from litellm.proxy.auth.model_checks import get_key_models
+
+ original_models = ["group-a", "extra-model"]
+ user_api_key_dict = UserAPIKeyAuth(
+ models=list(original_models), # give it a list
+ api_key="test-key",
+ )
+ model_access_groups = {
+ "group-a": ["model1", "model2"],
+ }
+
+ _ = get_key_models(
+ user_api_key_dict=user_api_key_dict,
+ proxy_model_list=["model1", "model2"],
+ model_access_groups=model_access_groups,
+ include_model_access_groups=False,
+ )
+ # The original models list on the auth object must be unchanged
+ assert user_api_key_dict.models == original_models
+
+
@pytest.mark.parametrize(
"key_models,team_models,proxy_model_list,model_list,expected",
[
diff --git a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py
index 79c2ed4158..f3f0ba56cb 100644
--- a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py
+++ b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py
@@ -13,8 +13,12 @@ from unittest.mock import MagicMock
import pytest
+import litellm.proxy.proxy_server
+from litellm.caching.dual_cache import DualCache
+from litellm.proxy._types import LiteLLM_JWTAuth, UserAPIKeyAuth
+from litellm.proxy.auth.handle_jwt import JWTHandler
from litellm.proxy.auth.route_checks import RouteChecks
-from litellm.proxy.auth.user_api_key_auth import get_api_key
+from litellm.proxy.auth.user_api_key_auth import get_api_key, user_api_key_auth
def test_get_api_key():
@@ -515,3 +519,169 @@ def test_proxy_admin_jwt_auth_handles_no_team_object():
assert result.team_metadata is None
assert result.org_id is None
assert result.end_user_id is None
+
+
+class TestJWTOAuth2Coexistence:
+ """
+ Test that JWT and OAuth2 auth can coexist on the same instance.
+
+ When both enable_jwt_auth and enable_oauth2_auth are True, the proxy should
+ route tokens based on their format:
+ - JWT tokens (3 dot-separated parts) -> JWT auth handler
+ - Opaque tokens -> OAuth2 auth handler
+ """
+
+ def test_is_jwt_detects_jwt_tokens(self):
+ """JWT tokens have 3 dot-separated parts."""
+ assert JWTHandler.is_jwt("header.payload.signature") is True
+ assert JWTHandler.is_jwt("eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyMSJ9.sig123") is True
+
+ def test_is_jwt_rejects_opaque_tokens(self):
+ """Opaque OAuth2 tokens do not have 3 dot-separated parts."""
+ assert JWTHandler.is_jwt("some-opaque-oauth2-token") is False
+ assert JWTHandler.is_jwt("sk-12345678") is False
+ assert JWTHandler.is_jwt("Bearer token") is False
+ assert JWTHandler.is_jwt("two.parts") is False
+
+ @pytest.mark.asyncio
+ async def test_both_enabled_opaque_token_uses_oauth2(self):
+ """
+ When both enable_jwt_auth and enable_oauth2_auth are True,
+ an opaque token should be handled by OAuth2 auth (not JWT).
+ """
+ opaque_token = "some-opaque-m2m-oauth2-token"
+
+ general_settings = {
+ "enable_oauth2_auth": True,
+ "enable_jwt_auth": True,
+ }
+
+ mock_oauth2_response = UserAPIKeyAuth(
+ api_key=opaque_token,
+ user_id="machine-client-1",
+ team_id="m2m-team",
+ )
+
+ mock_request = MagicMock()
+ mock_request.url.path = "/v1/chat/completions"
+ mock_request.headers = {"authorization": f"Bearer {opaque_token}"}
+ mock_request.query_params = {}
+
+ with patch("litellm.proxy.proxy_server.general_settings", general_settings), \
+ patch("litellm.proxy.proxy_server.premium_user", True), \
+ patch("litellm.proxy.proxy_server.master_key", "sk-master"), \
+ patch("litellm.proxy.proxy_server.prisma_client", None), \
+ patch("litellm.proxy.auth.user_api_key_auth.Oauth2Handler.check_oauth2_token", new_callable=AsyncMock, return_value=mock_oauth2_response) as mock_oauth2, \
+ patch("litellm.proxy.auth.user_api_key_auth.JWTAuthManager.auth_builder", new_callable=AsyncMock) as mock_jwt_auth:
+
+ litellm.proxy.proxy_server.jwt_handler.update_environment(
+ prisma_client=None,
+ user_api_key_cache=DualCache(),
+ litellm_jwtauth=LiteLLM_JWTAuth(),
+ )
+
+ result = await user_api_key_auth(
+ request=mock_request,
+ api_key=f"Bearer {opaque_token}",
+ )
+
+ # OAuth2 SHOULD be called for opaque tokens
+ mock_oauth2.assert_called_once_with(token=opaque_token)
+ # JWT auth should NOT be called
+ mock_jwt_auth.assert_not_called()
+ assert result.user_id == "machine-client-1"
+
+ @pytest.mark.asyncio
+ async def test_both_enabled_jwt_token_skips_oauth2(self):
+ """
+ When both enable_jwt_auth and enable_oauth2_auth are True,
+ a JWT-formatted token should skip OAuth2 and reach the JWT handler.
+ """
+ jwt_token = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyMSJ9.signature"
+
+ general_settings = {
+ "enable_oauth2_auth": True,
+ "enable_jwt_auth": True,
+ }
+
+ mock_jwt_result = {
+ "is_proxy_admin": True,
+ "team_object": None,
+ "user_object": None,
+ "end_user_object": None,
+ "org_object": None,
+ "token": jwt_token,
+ "team_id": "jwt-team",
+ "user_id": "jwt-human-user",
+ "end_user_id": None,
+ "org_id": None,
+ "team_membership": None,
+ "jwt_claims": {"sub": "user1"},
+ }
+
+ mock_request = MagicMock()
+ mock_request.url.path = "/v1/chat/completions"
+ mock_request.headers = {"authorization": f"Bearer {jwt_token}"}
+ mock_request.query_params = {}
+
+ with patch("litellm.proxy.proxy_server.general_settings", general_settings), \
+ patch("litellm.proxy.proxy_server.premium_user", True), \
+ patch("litellm.proxy.proxy_server.master_key", "sk-master"), \
+ patch("litellm.proxy.proxy_server.prisma_client", None), \
+ patch("litellm.proxy.auth.user_api_key_auth.Oauth2Handler.check_oauth2_token", new_callable=AsyncMock) as mock_oauth2, \
+ patch("litellm.proxy.auth.user_api_key_auth.JWTAuthManager.auth_builder", new_callable=AsyncMock, return_value=mock_jwt_result) as mock_jwt_auth:
+
+ litellm.proxy.proxy_server.jwt_handler.update_environment(
+ prisma_client=None,
+ user_api_key_cache=DualCache(),
+ litellm_jwtauth=LiteLLM_JWTAuth(),
+ )
+
+ result = await user_api_key_auth(
+ request=mock_request,
+ api_key=f"Bearer {jwt_token}",
+ )
+
+ # OAuth2 should NOT be called for JWT tokens
+ mock_oauth2.assert_not_called()
+ # JWT auth SHOULD be called
+ mock_jwt_auth.assert_called_once()
+ assert result.user_id == "jwt-human-user"
+
+ @pytest.mark.asyncio
+ async def test_only_oauth2_enabled_handles_all_tokens(self):
+ """
+ When only enable_oauth2_auth is True (no JWT), all LLM API tokens
+ should go through OAuth2 - backward compatible behavior.
+ """
+ jwt_like_token = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyMSJ9.signature"
+
+ general_settings = {
+ "enable_oauth2_auth": True,
+ "enable_jwt_auth": False,
+ }
+
+ mock_oauth2_response = UserAPIKeyAuth(
+ api_key=jwt_like_token,
+ user_id="oauth2-user",
+ )
+
+ mock_request = MagicMock()
+ mock_request.url.path = "/v1/chat/completions"
+ mock_request.headers = {"authorization": f"Bearer {jwt_like_token}"}
+ mock_request.query_params = {}
+
+ with patch("litellm.proxy.proxy_server.general_settings", general_settings), \
+ patch("litellm.proxy.proxy_server.premium_user", True), \
+ patch("litellm.proxy.proxy_server.master_key", "sk-master"), \
+ patch("litellm.proxy.proxy_server.prisma_client", None), \
+ patch("litellm.proxy.auth.user_api_key_auth.Oauth2Handler.check_oauth2_token", new_callable=AsyncMock, return_value=mock_oauth2_response) as mock_oauth2:
+
+ result = await user_api_key_auth(
+ request=mock_request,
+ api_key=f"Bearer {jwt_like_token}",
+ )
+
+ # OAuth2 should handle it since JWT auth is disabled
+ mock_oauth2.assert_called_once_with(token=jwt_like_token)
+ assert result.user_id == "oauth2-user"
diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py
index fa00fe614a..51450fd7e8 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py
@@ -1643,4 +1643,89 @@ async def test_get_user_daily_activity_aggregated_admin_global_view(monkeypatch)
model="gpt-4",
api_key=None,
timezone_offset_minutes=480,
- )
\ No newline at end of file
+ )
+
+
+@pytest.mark.asyncio
+async def test_delete_user_cleans_up_created_by_invitation_links(mocker):
+ """
+ Test that delete_user removes invitation links where the deleted user is the
+ creator (created_by) or updater (updated_by), not just the invited person (user_id).
+
+ This prevents FK constraint violations when deleting a user who created pending invites.
+ """
+ from litellm.proxy._types import DeleteUserRequest, UserAPIKeyAuth
+ from litellm.proxy.management_endpoints.internal_user_endpoints import delete_user
+
+ mock_prisma_client = mocker.MagicMock()
+
+ # Mock user lookup
+ mock_user_row = mocker.MagicMock()
+ mock_user_row.user_id = "admin-creator"
+ mock_user_row.user_email = "admin@example.com"
+ mock_user_row.teams = []
+ mock_user_row.json.return_value = "{}"
+ mock_user_row.model_dump.return_value = {
+ "user_id": "admin-creator",
+ "user_email": "admin@example.com",
+ "teams": [],
+ }
+
+ async def mock_find_unique(*args, **kwargs):
+ return mock_user_row
+
+ mock_prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock(
+ side_effect=mock_find_unique
+ )
+
+ # Mock find_many for teams (no teams)
+ mock_prisma_client.db.litellm_teamtable.find_many = mocker.AsyncMock(
+ return_value=[]
+ )
+
+ # Mock all delete_many calls
+ mock_prisma_client.db.litellm_verificationtoken.delete_many = mocker.AsyncMock(
+ return_value=0
+ )
+ mock_prisma_client.db.litellm_invitationlink.delete_many = mocker.AsyncMock(
+ return_value=1
+ )
+ mock_prisma_client.db.litellm_organizationmembership.delete_many = mocker.AsyncMock(
+ return_value=0
+ )
+ mock_prisma_client.db.litellm_teammembership.delete_many = mocker.AsyncMock(
+ return_value=0
+ )
+ mock_prisma_client.db.litellm_usertable.delete_many = mocker.AsyncMock(
+ return_value=1
+ )
+
+ mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
+
+ # Call delete_user
+ data = DeleteUserRequest(user_ids=["admin-creator"])
+ user_api_key_dict = UserAPIKeyAuth(
+ user_id="proxy-admin", user_role=LitellmUserRoles.PROXY_ADMIN
+ )
+
+ await delete_user(data=data, user_api_key_dict=user_api_key_dict)
+
+ # Verify invitation link deletion uses OR with user_id, created_by, updated_by
+ mock_prisma_client.db.litellm_invitationlink.delete_many.assert_called_once()
+ call_kwargs = mock_prisma_client.db.litellm_invitationlink.delete_many.call_args
+ where_clause = call_kwargs.kwargs.get("where") or call_kwargs[1].get("where")
+
+ assert "OR" in where_clause, "Should use OR to match user_id, created_by, and updated_by"
+ or_conditions = where_clause["OR"]
+ assert len(or_conditions) == 3, "Should have 3 OR conditions"
+
+ # Verify all three FK fields are covered
+ condition_keys = [list(c.keys())[0] for c in or_conditions]
+ assert "user_id" in condition_keys
+ assert "created_by" in condition_keys
+ assert "updated_by" in condition_keys
+
+ # Verify each condition uses {"in": ["admin-creator"]}
+ for condition in or_conditions:
+ field = list(condition.keys())[0]
+ assert condition[field] == {"in": ["admin-creator"]}
\ No newline at end of file
diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py b/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py
index 9a64e641b5..3249a7ec79 100644
--- a/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py
+++ b/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py
@@ -1071,9 +1071,10 @@ def test_spend_logs_redacts_request_and_response_when_turn_off_message_logging_e
response_result = _get_response_for_spend_logs_payload(payload=payload, kwargs=kwargs)
# When redaction is enabled and response is a dict (not ModelResponse),
- # perform_redaction returns {"text": "redacted-by-litellm"}
+ # perform_redaction redacts content in-place within the choices structure
parsed_response = json.loads(response_result)
- assert parsed_response == {"text": "redacted-by-litellm"}
+ assert parsed_response["choices"][0]["message"]["content"] == "redacted-by-litellm"
+ assert parsed_response["choices"][0]["message"]["role"] == "assistant"
@patch("litellm.secret_managers.main.get_secret_bool")
diff --git a/tests/test_litellm/test_litellm_params_reserved_keys.py b/tests/test_litellm/test_litellm_params_reserved_keys.py
new file mode 100644
index 0000000000..f49651bd81
--- /dev/null
+++ b/tests/test_litellm/test_litellm_params_reserved_keys.py
@@ -0,0 +1,92 @@
+"""
+Test that LiteLLM_Params and GenericLiteLLMParams handle reserved keys gracefully.
+
+This test verifies the fix for the bug where passing a dict containing 'self',
+'params', or '__class__' keys to LiteLLM_Params() would cause:
+ TypeError: LiteLLM_Params.__init__() got multiple values for argument 'self'
+"""
+
+import pytest
+
+from litellm.types.router import GenericLiteLLMParams, LiteLLM_Params
+
+
+class TestLiteLLMParamsReservedKeys:
+ """Test that reserved keys in input data are filtered out gracefully."""
+
+ def test_litellm_params_with_self_key(self):
+ """Test LiteLLM_Params handles 'self' key in input dict."""
+ params_dict = {"model": "gpt-4", "self": "some_value", "api_key": "test-key"}
+ params = LiteLLM_Params(**params_dict)
+ assert params.model == "gpt-4"
+ assert params.api_key == "test-key"
+ assert not hasattr(params, "self") or params.get("self") is None
+
+ def test_litellm_params_with_params_key(self):
+ """Test LiteLLM_Params handles 'params' key in input dict."""
+ params_dict = {"model": "gpt-4", "params": "bad_value"}
+ params = LiteLLM_Params(**params_dict)
+ assert params.model == "gpt-4"
+
+ def test_litellm_params_with_class_key(self):
+ """Test LiteLLM_Params handles '__class__' key in input dict."""
+ params_dict = {"model": "gpt-4", "__class__": "bad_value"}
+ params = LiteLLM_Params(**params_dict)
+ assert params.model == "gpt-4"
+
+ def test_generic_litellm_params_with_self_key(self):
+ """Test GenericLiteLLMParams handles 'self' key in input dict."""
+ params_dict = {"self": "some_value", "api_key": "test-key"}
+ params = GenericLiteLLMParams(**params_dict)
+ assert params.api_key == "test-key"
+
+ def test_generic_litellm_params_with_params_key(self):
+ """Test GenericLiteLLMParams handles 'params' key in input dict."""
+ params_dict = {"params": "bad_value", "api_key": "test-key"}
+ params = GenericLiteLLMParams(**params_dict)
+ assert params.api_key == "test-key"
+
+ def test_generic_litellm_params_with_class_key(self):
+ """Test GenericLiteLLMParams handles '__class__' key in input dict."""
+ params_dict = {"__class__": "bad_value", "api_key": "test-key"}
+ params = GenericLiteLLMParams(**params_dict)
+ assert params.api_key == "test-key"
+
+ def test_max_retries_string_conversion(self):
+ """Test that max_retries is converted from string to int."""
+ params = LiteLLM_Params(model="gpt-4", max_retries="5")
+ assert params.max_retries == 5
+ assert isinstance(params.max_retries, int)
+
+ def test_extra_fields_preserved(self):
+ """Test that extra fields are preserved when reserved keys are filtered."""
+ params_dict = {
+ "model": "gpt-4",
+ "self": "ignored",
+ "custom_field": "custom_value",
+ }
+ params = LiteLLM_Params(**params_dict)
+ assert params.model == "gpt-4"
+ assert params.custom_field == "custom_value"
+
+ def test_normal_instantiation_still_works(self):
+ """Test that normal instantiation without reserved keys works."""
+ params = LiteLLM_Params(
+ model="gpt-4", api_key="test-key", custom_llm_provider="openai"
+ )
+ assert params.model == "gpt-4"
+ assert params.api_key == "test-key"
+ assert params.custom_llm_provider == "openai"
+
+ def test_multiple_reserved_keys(self):
+ """Test filtering multiple reserved keys at once."""
+ params_dict = {
+ "model": "gpt-4",
+ "self": "value1",
+ "params": "value2",
+ "__class__": "value3",
+ "api_key": "test-key",
+ }
+ params = LiteLLM_Params(**params_dict)
+ assert params.model == "gpt-4"
+ assert params.api_key == "test-key"
diff --git a/tests/test_litellm/test_main.py b/tests/test_litellm/test_main.py
index 39f7ca33fb..3a43b1229d 100644
--- a/tests/test_litellm/test_main.py
+++ b/tests/test_litellm/test_main.py
@@ -609,6 +609,24 @@ def test_responses_api_bridge_check_strips_responses_prefix():
assert model_info["mode"] == "responses"
+def test_responses_api_bridge_check_gpt_5_4_pro():
+ """Test that gpt-5.4-pro routes through responses API bridge, not chat completions.
+
+ Regression test for https://github.com/BerriAI/litellm/issues/23014
+ gpt-5.4-pro is a responses-only model and must not be sent to /v1/chat/completions.
+ """
+ from litellm.main import responses_api_bridge_check
+
+ for model_name in ["gpt-5.4-pro", "gpt-5.4-pro-2026-03-05"]:
+ model_info, model = responses_api_bridge_check(
+ model=model_name,
+ custom_llm_provider="openai",
+ )
+ assert model_info.get("mode") == "responses", (
+ f"{model_name} should have mode='responses', got '{model_info.get('mode')}'"
+ )
+
+
def test_responses_api_bridge_check_handles_exception():
"""Test that responses_api_bridge_check handles exceptions and still processes responses/ models."""
from litellm.main import responses_api_bridge_check
diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py
index c2df3db0e3..c4073cb96d 100644
--- a/tests/test_litellm/test_utils.py
+++ b/tests/test_litellm/test_utils.py
@@ -507,6 +507,7 @@ def validate_model_cost_values(model_data, exceptions=None):
"input_cost_per_audio_token",
"output_cost_per_audio_token",
"output_cost_per_image_token",
+ "output_cost_per_image_token_batches",
"input_cost_per_audio_per_second",
"input_cost_per_video_per_second",
"input_cost_per_token_above_128k_tokens",
@@ -696,6 +697,7 @@ def test_aaamodel_prices_and_context_window_json_is_valid():
"output_cost_per_character_above_128k_tokens": {"type": "number"},
"output_cost_per_image": {"type": "number"},
"output_cost_per_image_token": {"type": "number"},
+ "output_cost_per_image_token_batches": {"type": "number"},
"output_cost_per_pixel": {"type": "number"},
"output_cost_per_second": {"type": "number"},
"output_cost_per_token": {"type": "number"},
diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx
index 4fd513b0d2..35f87e8770 100644
--- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx
+++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx
@@ -262,8 +262,8 @@ it("should display user email correctly", async () => {
});
});
-it("should show skeleton loaders when isLoading is true", () => {
- // Mock loading state
+it("should show loading message only on initial load (isPending)", () => {
+ // Mock initial loading state
mockUseKeys.mockReturnValue({
data: null,
isPending: true,
@@ -283,7 +283,7 @@ it("should show skeleton loaders when isLoading is true", () => {
renderWithProviders(