Merge branch 'main' into litellm_add_anthropic_tool_call_results

This commit is contained in:
Sameer Kankute 2026-01-12 18:13:06 +05:30 committed by GitHub
commit ec3e30a221
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 1549 additions and 241 deletions

View file

@ -5,6 +5,7 @@ on:
inputs:
tag:
description: "The tag version you want to build"
required: true
release_type:
description: "The release type you want to build. Can be 'latest', 'stable', 'dev', 'rc'"
type: string
@ -336,9 +337,9 @@ jobs:
run: |
CHART_LIST=$(helm show chart oci://${{ env.REGISTRY }}/${{ env.REPO_OWNER }}/${{ env.CHART_NAME }} 2>/dev/null || true)
if [ -z "${CHART_LIST}" ]; then
echo "current-version=0.1.0" | tee -a $GITHUB_OUTPUT
echo "current-version=1.0.0" | tee -a $GITHUB_OUTPUT
else
# Extract version and strip any prerelease suffix (e.g., 0.1.827-latest -> 0.1.827)
# Extract version and strip any prerelease suffix (e.g., 1.0.5-latest -> 1.0.5)
VERSION=$(printf '%s' "${CHART_LIST}" | grep '^version:' | awk 'BEGIN{FS=":"}{print $2}' | tr -d " " | cut -d'-' -f1)
echo "current-version=${VERSION}" | tee -a $GITHUB_OUTPUT
fi
@ -350,28 +351,42 @@ jobs:
id: bump_version
uses: christian-draeger/increment-semantic-version@1.1.0
with:
current-version: ${{ steps.current_version.outputs.current-version || '0.1.0' }}
current-version: ${{ steps.current_version.outputs.current-version || '1.0.0' }}
version-fragment: 'bug'
# Add suffix for non-stable releases (semantic versioning)
- name: Calculate chart version with prerelease suffix
- name: Calculate chart and app versions
id: chart_version
shell: bash
run: |
BASE_VERSION="${{ steps.bump_version.outputs.next-version || '0.1.0' }}"
BASE_VERSION="${{ steps.bump_version.outputs.next-version || '1.0.0' }}"
RELEASE_TYPE="${{ github.event.inputs.release_type }}"
INPUT_TAG="${{ github.event.inputs.tag }}"
# Chart version (independent Helm chart versioning with release type suffix)
if [ "$RELEASE_TYPE" = "stable" ]; then
echo "version=${BASE_VERSION}" | tee -a $GITHUB_OUTPUT
else
echo "version=${BASE_VERSION}-${RELEASE_TYPE}" | tee -a $GITHUB_OUTPUT
fi
# App version (must match Docker tags)
# stable/rc releases: Docker creates main-{tag}, so use the tag
# latest/dev releases: Docker only creates main-{release_type}, so use release_type
if [ "$RELEASE_TYPE" = "stable" ] || [ "$RELEASE_TYPE" = "rc" ]; then
APP_VERSION="${INPUT_TAG}"
else
APP_VERSION="${RELEASE_TYPE}"
fi
echo "app_version=${APP_VERSION}" | tee -a $GITHUB_OUTPUT
- uses: ./.github/actions/helm-oci-chart-releaser
with:
name: ${{ env.CHART_NAME }}
repository: ${{ env.REPO_OWNER }}
tag: ${{ github.event.inputs.chartVersion || steps.chart_version.outputs.version || '0.1.0' }}
app_version: ${{ steps.current_app_tag.outputs.latest_tag }}
tag: ${{ github.event.inputs.chartVersion || steps.chart_version.outputs.version || '1.0.0' }}
app_version: ${{ steps.chart_version.outputs.app_version }}
path: deploy/charts/${{ env.CHART_NAME }}
registry: ${{ env.REGISTRY }}
registry_username: ${{ github.actor }}

View file

@ -18,13 +18,13 @@ type: application
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.4.10
version: 1.0.0
# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
appVersion: v1.50.2
appVersion: v1.80.12
dependencies:
- name: "postgresql"

View file

@ -142,7 +142,47 @@ def completion(
- `tool_call_id`: *str (optional)* - Tool call that this message is responding to.
[**See All Message Values**](https://github.com/BerriAI/litellm/blob/8600ec77042dacad324d3879a2bd918fc6a719fa/litellm/types/llms/openai.py#L392)
[**See All Message Values**](https://github.com/BerriAI/litellm/blob/main/litellm/types/llms/openai.py#L664)
#### Content Types
`content` can be a string (text only) or a list of content blocks (multimodal):
| Type | Description | Docs |
|------|-------------|------|
| `text` | Text content | [Type Definition](https://github.com/BerriAI/litellm/blob/main/litellm/types/llms/openai.py#L598) |
| `image_url` | Images | [Vision](./vision.md) |
| `input_audio` | Audio input | [Audio](./audio.md) |
| `video_url` | Video input | [Type Definition](https://github.com/BerriAI/litellm/blob/main/litellm/types/llms/openai.py#L625) |
| `file` | Files | [Document Understanding](./document_understanding.md) |
| `document` | Documents/PDFs | [Document Understanding](./document_understanding.md) |
**Examples:**
```python
# Text
messages=[{"role": "user", "content": [{"type": "text", "text": "Hello!"}]}]
# Image
messages=[{"role": "user", "content": [{"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}]}]
# Audio
messages=[{"role": "user", "content": [{"type": "input_audio", "input_audio": {"data": "<base64>", "format": "wav"}}]}]
# Video
messages=[{"role": "user", "content": [{"type": "video_url", "video_url": {"url": "https://example.com/video.mp4"}}]}]
# File
messages=[{"role": "user", "content": [{"type": "file", "file": {"file_id": "https://example.com/doc.pdf"}}]}]
# Document
messages=[{"role": "user", "content": [{"type": "document", "source": {"type": "text", "media_type": "application/pdf", "data": "<base64>"}}]}]
# Combining multiple types (multimodal)
messages=[{"role": "user", "content": [
{"type": "text", "text": "Generate a product description based on this image"},
{"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}
]}]
```
## Optional Fields

View file

@ -4,6 +4,12 @@ import TabItem from '@theme/TabItem';
# High Availability Setup (Resolve DB Deadlocks)
:::tip Essential for Production
This configuration is **required** for production deployments handling 1000+ requests per second. Without Redis configured, you may experience PostgreSQL connection exhaustion (`FATAL: sorry, too many clients already`).
:::
Resolve any Database Deadlocks you see in high traffic by using this setup
## What causes the problem?

View file

@ -359,6 +359,26 @@ LiteLLM is compatible with several SDKs - including OpenAI SDK, Anthropic SDK, M
### Deploy with Database
##### Docker, Kubernetes, Helm Chart
:::warning High Traffic Deployments (1000+ RPS)
If you expect high traffic (1000+ requests per second), **Redis is required** to prevent database connection exhaustion and deadlocks.
Add this to your config:
```yaml
general_settings:
use_redis_transaction_buffer: true
litellm_settings:
cache: true
cache_params:
type: redis
host: your-redis-host
```
See [Resolve DB Deadlocks](/docs/proxy/db_deadlocks) for details.
:::
Requirements:
- Need a postgres database (e.g. [Supabase](https://supabase.com/), [Neon](https://neon.tech/), etc) Set `DATABASE_URL=postgresql://<user>:<password>@<host>:<port>/<dbname>` in your env
- Set a `LITELLM_MASTER_KEY`, this is your Proxy Admin key - you can use this to create other keys (🚨 must start with `sk-`)

View file

@ -264,8 +264,15 @@ model_list:
model: azure/gpt-4-fallback
api_key: os.environ/AZURE_API_KEY_2
order: 2 # 👈 Used when order=1 is unavailable
router_settings:
enable_pre_call_checks: true # 👈 Required for 'order' to work
```
:::important
The `order` parameter requires `enable_pre_call_checks: true` in `router_settings`.
:::
If `order=1` deployment is unavailable (e.g., rate-limited), the router falls back to `order=2` deployments.
### When You'll See Load Balancing in Action

View file

@ -165,6 +165,7 @@ general_settings:
target: string # Target URL for forwarding
auth: boolean # Enable LiteLLM authentication (Enterprise)
forward_headers: boolean # Forward all incoming headers
include_subpath: boolean # If true, forwards requests to sub-paths (default: false)
headers: # Custom headers to add
Authorization: string # Auth header for target API
content-type: string # Request content type
@ -181,6 +182,23 @@ general_settings:
- **LANGFUSE_PUBLIC_KEY/SECRET_KEY**: For Langfuse integration
- **Custom headers**: Any additional key-value pairs
### Sub-path Routing
By default, pass-through endpoints only match the **exact path** specified. To forward requests to sub-paths, set `include_subpath: true`:
```yaml
general_settings:
pass_through_endpoints:
- path: "/custom-api" # Any path prefix you choose
target: "https://api.example.com"
include_subpath: true # Forward /custom-api/*, not just /custom-api
```
| Setting | Behavior |
|---------|----------|
| `include_subpath: false` (default) | Only `/custom-api` is forwarded |
| `include_subpath: true` | `/custom-api`, `/custom-api/v1/chat`, `/custom-api/anything` are all forwarded |
---
## Advanced: Custom Adapters

View file

@ -861,9 +861,13 @@ model_list = [
},
]
router = Router(model_list=model_list)
router = Router(model_list=model_list, enable_pre_call_checks=True) # 👈 Required for 'order' to work
```
:::important
The `order` parameter requires `enable_pre_call_checks=True` to be set on the Router.
:::
</TabItem>
<TabItem value="proxy" label="PROXY">
@ -880,6 +884,9 @@ model_list:
model: azure/gpt-4-fallback
api_key: os.environ/AZURE_API_KEY_2
order: 2 # 👈 Used when order=1 is unavailable
router_settings:
enable_pre_call_checks: true # 👈 Required for 'order' to work
```
</TabItem>

View file

@ -37,9 +37,14 @@ class GenerateContentToCompletionHandler:
completion_kwargs: Dict[str, Any] = dict(completion_request)
# feed metadata for custom callback
if extra_kwargs is not None and "metadata" in extra_kwargs:
completion_kwargs["metadata"] = extra_kwargs["metadata"]
# Forward extra_kwargs that should be passed to completion call
if extra_kwargs is not None:
# Forward metadata for custom callback
if "metadata" in extra_kwargs:
completion_kwargs["metadata"] = extra_kwargs["metadata"]
# Forward extra_headers for providers that require custom headers (e.g., github_copilot)
if "extra_headers" in extra_kwargs:
completion_kwargs["extra_headers"] = extra_kwargs["extra_headers"]
if stream:
completion_kwargs["stream"] = stream

View file

@ -330,6 +330,7 @@ def generate_content(
tools=tools,
_is_async=_is_async,
litellm_params=setup_result.litellm_params,
extra_headers=extra_headers,
**kwargs,
)
@ -422,6 +423,7 @@ async def agenerate_content_stream(
litellm_params=setup_result.litellm_params,
tools=tools,
stream=True,
extra_headers=extra_headers,
**kwargs,
)
)
@ -507,6 +509,7 @@ def generate_content_stream(
_is_async=_is_async,
litellm_params=setup_result.litellm_params,
stream=True,
extra_headers=extra_headers,
**kwargs,
)

View file

@ -95,7 +95,9 @@ def handle_messages_with_content_list_to_str_conversion(
return messages
def strip_name_from_message(message: AllMessageValues, allowed_name_roles: List[str] = ["user"]) -> AllMessageValues:
def strip_name_from_message(
message: AllMessageValues, allowed_name_roles: List[str] = ["user"]
) -> AllMessageValues:
"""
Removes 'name' from message
"""
@ -104,6 +106,7 @@ def strip_name_from_message(message: AllMessageValues, allowed_name_roles: List[
msg_copy.pop("name", None) # type: ignore
return msg_copy
def strip_name_from_messages(
messages: List[AllMessageValues], allowed_name_roles: List[str] = ["user"]
) -> List[AllMessageValues]:
@ -444,7 +447,7 @@ def update_responses_input_with_model_file_ids(
"""
Updates responses API input with provider-specific file IDs.
File IDs are always inside the content array, not as direct input_file items.
For managed files (unified file IDs), decodes the base64-encoded unified file ID
and extracts the llm_output_file_id directly.
"""
@ -452,25 +455,28 @@ def update_responses_input_with_model_file_ids(
_is_base64_encoded_unified_file_id,
convert_b64_uid_to_unified_uid,
)
if isinstance(input, str):
return input
if not isinstance(input, list):
return input
updated_input = []
for item in input:
if not isinstance(item, dict):
updated_input.append(item)
continue
updated_item = item.copy()
content = item.get("content")
if isinstance(content, list):
updated_content = []
for content_item in content:
if isinstance(content_item, dict) and content_item.get("type") == "input_file":
if (
isinstance(content_item, dict)
and content_item.get("type") == "input_file"
):
file_id = content_item.get("file_id")
if file_id:
# Check if this is a managed file ID (base64-encoded unified file ID)
@ -478,7 +484,9 @@ def update_responses_input_with_model_file_ids(
if is_unified_file_id:
unified_file_id = convert_b64_uid_to_unified_uid(file_id)
if "llm_output_file_id," in unified_file_id:
provider_file_id = unified_file_id.split("llm_output_file_id,")[1].split(";")[0]
provider_file_id = unified_file_id.split(
"llm_output_file_id,"
)[1].split(";")[0]
else:
# Fallback: keep original if we can't extract
provider_file_id = file_id
@ -492,9 +500,9 @@ def update_responses_input_with_model_file_ids(
else:
updated_content.append(content_item)
updated_item["content"] = updated_content
updated_input.append(updated_item)
return updated_input
@ -697,9 +705,9 @@ def _get_image_mime_type_from_url(url: str) -> Optional[str]:
video/flv
"""
from urllib.parse import urlparse
url = url.lower()
# Parse URL to extract path without query parameters
# This handles URLs like: https://example.com/image.jpg?signature=...
parsed = urlparse(url)
@ -744,28 +752,28 @@ def infer_content_type_from_url_and_content(
) -> str:
"""
Infer content type from URL extension and binary content when content-type header is missing or generic.
This helper implements a fallback strategy for determining MIME types when HTTP headers
are missing or provide generic values (like binary/octet-stream). It's commonly used
when processing images and documents from various sources (S3, URLs, etc.).
Fallback Strategy:
1. If current_content_type is valid (not None and not generic octet-stream), return it
2. Try to infer from URL extension (handles query parameters)
3. Try to detect from binary content signature (magic bytes)
4. Raise ValueError if all methods fail
Args:
url: The URL of the content (used to extract file extension)
content: The binary content (first ~100 bytes are sufficient for detection)
current_content_type: The current content-type from headers (may be None or generic)
Returns:
str: The inferred MIME type (e.g., "image/png", "application/pdf")
Raises:
ValueError: If content type cannot be determined by any method
Example:
>>> content_type = infer_content_type_from_url_and_content(
... url="https://s3.amazonaws.com/bucket/image.png?AWSAccessKeyId=123",
@ -776,14 +784,14 @@ def infer_content_type_from_url_and_content(
"image/png"
"""
from litellm.litellm_core_utils.token_counter import get_image_type
# If we have a valid content type that's not generic, use it
if current_content_type and current_content_type not in [
"binary/octet-stream",
"application/octet-stream",
]:
return current_content_type
# Extension to MIME type mapping
# Supports images, documents, and other common file types
extension_to_mime = {
@ -804,14 +812,14 @@ def infer_content_type_from_url_and_content(
"txt": "text/plain",
"md": "text/markdown",
}
# Try to infer from URL extension
if url:
extension = url.split(".")[-1].lower().split("?")[0] # Remove query params
inferred_type = extension_to_mime.get(extension)
if inferred_type:
return inferred_type
# Try to detect from binary content signature (magic bytes)
if content:
detected_type = get_image_type(content[:100])
@ -825,7 +833,7 @@ def infer_content_type_from_url_and_content(
}
if detected_type in type_to_mime:
return type_to_mime[detected_type]
# If all fallbacks failed, raise error
raise ValueError(
f"Unable to determine content type from URL: {url}. "
@ -1085,7 +1093,9 @@ def _parse_content_for_reasoning(
return None, message_text
reasoning_match = re.match(
r"<(?:think|thinking|budget:thinking)>(.*?)</(?:think|thinking|budget:thinking)>(.*)", message_text, re.DOTALL
r"<(?:think|thinking|budget:thinking)>(.*?)</(?:think|thinking|budget:thinking)>(.*)",
message_text,
re.DOTALL,
)
if reasoning_match:
@ -1135,3 +1145,47 @@ def extract_images_from_message(message: AllMessageValues) -> List[str]:
elif isinstance(image_url, dict) and "url" in image_url:
images.append(_extract_base64_data(image_url["url"]))
return images
def parse_tool_call_arguments(
arguments: Optional[str],
tool_name: Optional[str] = None,
context: Optional[str] = None,
) -> Dict[str, Any]:
"""
Parse tool call arguments from a JSON string.
This function handles malformed JSON gracefully by raising a ValueError
with context about what failed and what the problematic input was.
Args:
arguments: The JSON string containing tool arguments, or None.
tool_name: Optional name of the tool (for error messages).
context: Optional context string (e.g., "Anthropic Messages API").
Returns:
Parsed arguments as a dictionary. Returns empty dict if arguments is None or empty.
Raises:
ValueError: If the arguments string is not valid JSON.
"""
import json
if not arguments:
return {}
try:
return json.loads(arguments)
except json.JSONDecodeError as e:
error_parts = ["Failed to parse tool call arguments"]
if tool_name:
error_parts.append(f"for tool '{tool_name}'")
if context:
error_parts.append(f"({context})")
error_message = (
" ".join(error_parts) + f". Error: {str(e)}. Arguments: {arguments}"
)
raise ValueError(error_message) from e

View file

@ -44,6 +44,7 @@ from .common_utils import (
convert_content_list_to_str,
infer_content_type_from_url_and_content,
is_non_content_values_set,
parse_tool_call_arguments,
)
from .image_handling import convert_url_to_base64
@ -911,13 +912,13 @@ def convert_to_anthropic_image_obj(
def create_anthropic_image_param(
image_url_input: Union[str, dict],
image_url_input: Union[str, dict],
format: Optional[str] = None,
is_bedrock_invoke: bool = False
is_bedrock_invoke: bool = False,
) -> AnthropicMessagesImageParam:
"""
Create an AnthropicMessagesImageParam from an image URL input.
Supports both URL references (for HTTP/HTTPS URLs) and base64 encoding.
"""
# Extract URL and format from input
@ -927,7 +928,7 @@ def create_anthropic_image_param(
image_url = image_url_input.get("url", "")
if format is None:
format = image_url_input.get("format")
# Check if the image URL is an HTTP/HTTPS URL
if image_url.startswith("http://") or image_url.startswith("https://"):
# For Bedrock invoke and Vertex AI Anthropic, always convert URLs to base64
@ -1031,9 +1032,11 @@ def convert_to_anthropic_tool_invoke_xml(tool_calls: list) -> str:
tool_function = get_attribute_or_key(tool, "function")
tool_name = get_attribute_or_key(tool_function, "name")
tool_arguments = get_attribute_or_key(tool_function, "arguments")
parsed_args = parse_tool_call_arguments(
tool_arguments, tool_name=tool_name, context="Anthropic XML tool invoke"
)
parameters = "".join(
f"<{param}>{val}</{param}>\n"
for param, val in json.loads(tool_arguments).items()
f"<{param}>{val}</{param}>\n" for param, val in parsed_args.items()
)
invokes += (
"<invoke>\n"
@ -1071,8 +1074,14 @@ def anthropic_messages_pt_xml(messages: list):
if isinstance(messages[msg_i]["content"], list):
for m in messages[msg_i]["content"]:
if m.get("type", "") == "image_url":
format = m["image_url"].get("format") if isinstance(m["image_url"], dict) else None
image_param = create_anthropic_image_param(m["image_url"], format=format)
format = (
m["image_url"].get("format")
if isinstance(m["image_url"], dict)
else None
)
image_param = create_anthropic_image_param(
m["image_url"], format=format
)
# Convert to dict format for XML version
source = image_param["source"]
if isinstance(source, dict) and source.get("type") == "url":
@ -1381,10 +1390,10 @@ def convert_to_gemini_tool_call_invoke(
if tool_calls is not None:
for idx, tool in enumerate(tool_calls):
if "function" in tool:
gemini_function_call: Optional[
VertexFunctionCall
] = _gemini_tool_call_invoke_helper(
function_call_params=tool["function"]
gemini_function_call: Optional[VertexFunctionCall] = (
_gemini_tool_call_invoke_helper(
function_call_params=tool["function"]
)
)
if gemini_function_call is not None:
part_dict: VertexPartType = {
@ -1484,10 +1493,10 @@ def convert_to_gemini_tool_call_result(
}
"""
from litellm.types.llms.vertex_ai import BlobType
content_str: str = ""
inline_data: Optional[BlobType] = None
if "content" in message:
if isinstance(message["content"], str):
content_str = message["content"]
@ -1500,15 +1509,21 @@ def convert_to_gemini_tool_call_result(
elif content_type in ("input_image", "image_url"):
# Extract image for inline_data (for Computer Use screenshots and tool results)
image_url_data = content.get("image_url", "")
image_url = image_url_data.get("url", "") if isinstance(image_url_data, dict) else image_url_data
image_url = (
image_url_data.get("url", "")
if isinstance(image_url_data, dict)
else image_url_data
)
if image_url:
# Convert image to base64 blob format for Gemini
try:
image_obj = convert_to_anthropic_image_obj(image_url, format=None)
image_obj = convert_to_anthropic_image_obj(
image_url, format=None
)
inline_data = BlobType(
data=image_obj["data"],
mime_type=image_obj["media_type"]
mime_type=image_obj["media_type"],
)
except Exception as e:
verbose_logger.warning(
@ -1541,6 +1556,7 @@ def convert_to_gemini_tool_call_result(
response_data: dict
try:
import json
if content_str.strip().startswith("{") or content_str.strip().startswith("["):
# Try to parse as JSON (for Computer Use structured responses)
parsed = json.loads(content_str)
@ -1553,7 +1569,7 @@ def convert_to_gemini_tool_call_result(
except (json.JSONDecodeError, ValueError):
# Not valid JSON, wrap in content field
response_data = {"content": content_str}
# We can't determine from openai message format whether it's a successful or
# error call result so default to the successful result template
_function_response = VertexFunctionResponse(
@ -1562,7 +1578,7 @@ def convert_to_gemini_tool_call_result(
# Create part with function_response, and optionally inline_data for images (Computer Use)
_part: VertexPartType = {"function_response": _function_response}
# For Computer Use, if we have an image, we need separate parts:
# - One part with function_response
# - One part with inline_data
@ -1570,19 +1586,19 @@ def convert_to_gemini_tool_call_result(
if inline_data:
image_part: VertexPartType = {"inline_data": inline_data}
return [_part, image_part]
return _part
def _sanitize_anthropic_tool_use_id(tool_use_id: str) -> str:
"""
Sanitize tool_use_id to match Anthropic's required pattern: ^[a-zA-Z0-9_-]+$
Anthropic requires tool_use_id to only contain alphanumeric characters, underscores, and hyphens.
This function replaces any invalid characters with underscores.
"""
# Replace any character that's not alphanumeric, underscore, or hyphen with underscore
sanitized = re.sub(r'[^a-zA-Z0-9_-]', '_', tool_use_id)
sanitized = re.sub(r"[^a-zA-Z0-9_-]", "_", tool_use_id)
# Ensure it's not empty (fallback to a default if needed)
if not sanitized:
sanitized = "tool_use_id"
@ -1644,8 +1660,14 @@ def convert_to_anthropic_tool_result(
)
)
elif content["type"] == "image_url":
format = content["image_url"].get("format") if isinstance(content["image_url"], dict) else None
_anthropic_image_param = create_anthropic_image_param(content["image_url"], format=format)
format = (
content["image_url"].get("format")
if isinstance(content["image_url"], dict)
else None
)
_anthropic_image_param = create_anthropic_image_param(
content["image_url"], format=format
)
_anthropic_image_param = add_cache_control_to_content(
anthropic_content_element=_anthropic_image_param,
original_content_element=content,
@ -1665,7 +1687,9 @@ def convert_to_anthropic_tool_result(
# We can't determine from openai message format whether it's a successful or
# error call result so default to the successful result template
anthropic_tool_result = AnthropicMessagesToolResultParam(
type="tool_result", tool_use_id=sanitized_tool_use_id, content=anthropic_content
type="tool_result",
tool_use_id=sanitized_tool_use_id,
content=anthropic_content,
)
if message["role"] == "function":
@ -1674,7 +1698,9 @@ def convert_to_anthropic_tool_result(
# Sanitize tool_use_id to match Anthropic's pattern requirement: ^[a-zA-Z0-9_-]+$
sanitized_tool_use_id = _sanitize_anthropic_tool_use_id(tool_call_id)
anthropic_tool_result = AnthropicMessagesToolResultParam(
type="tool_result", tool_use_id=sanitized_tool_use_id, content=anthropic_content
type="tool_result",
tool_use_id=sanitized_tool_use_id,
content=anthropic_content,
)
if anthropic_tool_result is None:
@ -1690,12 +1716,17 @@ def convert_function_to_anthropic_tool_invoke(
try:
_name = get_attribute_or_key(function_call, "name") or ""
_arguments = get_attribute_or_key(function_call, "arguments")
tool_input = parse_tool_call_arguments(
_arguments, tool_name=_name, context="Anthropic function to tool invoke"
)
anthropic_tool_invoke = [
AnthropicMessagesToolUseParam(
type="tool_use",
id=str(uuid.uuid4()),
name=_name,
input=json.loads(_arguments) if _arguments else {},
input=tool_input,
)
]
return anthropic_tool_invoke
@ -1749,7 +1780,9 @@ def convert_to_anthropic_tool_invoke(
Fixes: https://github.com/BerriAI/litellm/issues/17737
"""
anthropic_tool_invoke: List[Union[AnthropicMessagesToolUseParam, Dict[str, Any]]] = []
anthropic_tool_invoke: List[
Union[AnthropicMessagesToolUseParam, Dict[str, Any]]
] = []
for tool in tool_calls:
if not get_attribute_or_key(tool, "type") == "function":
@ -1760,10 +1793,10 @@ def convert_to_anthropic_tool_invoke(
str,
get_attribute_or_key(get_attribute_or_key(tool, "function"), "name"),
)
tool_input = json.loads(
get_attribute_or_key(
get_attribute_or_key(tool, "function"), "arguments"
)
tool_input = parse_tool_call_arguments(
get_attribute_or_key(get_attribute_or_key(tool, "function"), "arguments"),
tool_name=tool_name,
context="Anthropic tool invoke",
)
# Check if this is a server-side tool (web_search, tool_search, etc.)
@ -2015,11 +2048,17 @@ def anthropic_messages_pt( # noqa: PLR0915
for m in user_message_types_block["content"]:
if m.get("type", "") == "image_url":
m = cast(ChatCompletionImageObject, m)
format = m["image_url"].get("format") if isinstance(m["image_url"], dict) else None
format = (
m["image_url"].get("format")
if isinstance(m["image_url"], dict)
else None
)
# Convert ChatCompletionImageUrlObject to dict if needed
image_url_value = m["image_url"]
if isinstance(image_url_value, str):
image_url_input: Union[str, dict[str, Any]] = image_url_value
image_url_input: Union[str, dict[str, Any]] = (
image_url_value
)
else:
# ChatCompletionImageUrlObject or dict case - convert to dict
image_url_input = {
@ -2029,20 +2068,26 @@ def anthropic_messages_pt( # noqa: PLR0915
# Bedrock invoke models have format: invoke/...
# Vertex AI Anthropic also doesn't support URL sources for images
is_bedrock_invoke = model.lower().startswith("invoke/")
is_vertex_ai = llm_provider.startswith("vertex_ai") if llm_provider else False
is_vertex_ai = (
llm_provider.startswith("vertex_ai")
if llm_provider
else False
)
force_base64 = is_bedrock_invoke or is_vertex_ai
_anthropic_content_element = create_anthropic_image_param(
image_url_input, format=format, is_bedrock_invoke=force_base64
)
image_url_input,
format=format,
is_bedrock_invoke=force_base64,
)
_content_element = add_cache_control_to_content(
anthropic_content_element=_anthropic_content_element,
original_content_element=dict(m),
)
if "cache_control" in _content_element:
_anthropic_content_element[
"cache_control"
] = _content_element["cache_control"]
_anthropic_content_element["cache_control"] = (
_content_element["cache_control"]
)
user_content.append(_anthropic_content_element)
elif m.get("type", "") == "text":
m = cast(ChatCompletionTextObject, m)
@ -2080,9 +2125,9 @@ def anthropic_messages_pt( # noqa: PLR0915
)
if "cache_control" in _content_element:
_anthropic_content_text_element[
"cache_control"
] = _content_element["cache_control"]
_anthropic_content_text_element["cache_control"] = (
_content_element["cache_control"]
)
user_content.append(_anthropic_content_text_element)
@ -2178,18 +2223,27 @@ def anthropic_messages_pt( # noqa: PLR0915
): # support assistant tool invoke conversion
# Get web_search_results from provider_specific_fields for server_tool_use reconstruction
# Fixes: https://github.com/BerriAI/litellm/issues/17737
_provider_specific_fields_raw = assistant_content_block.get("provider_specific_fields")
_provider_specific_fields_raw = assistant_content_block.get(
"provider_specific_fields"
)
_provider_specific_fields: Dict[str, Any] = {}
if isinstance(_provider_specific_fields_raw, dict):
_provider_specific_fields = cast(Dict[str, Any], _provider_specific_fields_raw)
_web_search_results = _provider_specific_fields.get("web_search_results")
_provider_specific_fields = cast(
Dict[str, Any], _provider_specific_fields_raw
)
_web_search_results = _provider_specific_fields.get(
"web_search_results"
)
tool_invoke_results = convert_to_anthropic_tool_invoke(
assistant_tool_calls,
web_search_results=_web_search_results,
)
# AnthropicMessagesAssistantMessageValues includes AnthropicMessagesToolUseParam
assistant_content.extend(
cast(List[AnthropicMessagesAssistantMessageValues], tool_invoke_results)
cast(
List[AnthropicMessagesAssistantMessageValues],
tool_invoke_results,
)
)
assistant_function_call = assistant_content_block.get("function_call")
@ -3252,14 +3306,18 @@ def _convert_to_bedrock_tool_call_result(
"""
-
"""
tool_result_content_blocks:List[BedrockToolResultContentBlock] = []
tool_result_content_blocks: List[BedrockToolResultContentBlock] = []
if isinstance(message["content"], str):
tool_result_content_blocks.append(BedrockToolResultContentBlock(text=message["content"]))
tool_result_content_blocks.append(
BedrockToolResultContentBlock(text=message["content"])
)
elif isinstance(message["content"], List):
content_list = message["content"]
for content in content_list:
if content["type"] == "text":
tool_result_content_blocks.append(BedrockToolResultContentBlock(text=content["text"]))
tool_result_content_blocks.append(
BedrockToolResultContentBlock(text=content["text"])
)
elif content["type"] == "image_url":
format: Optional[str] = None
if isinstance(content["image_url"], dict):
@ -3267,12 +3325,14 @@ def _convert_to_bedrock_tool_call_result(
format = content["image_url"].get("format")
else:
image_url = content["image_url"]
_block:BedrockContentBlock = BedrockImageProcessor.process_image_sync(
_block: BedrockContentBlock = BedrockImageProcessor.process_image_sync(
image_url=image_url,
format=format,
)
if "image" in _block:
tool_result_content_blocks.append(BedrockToolResultContentBlock(image=_block["image"]))
tool_result_content_blocks.append(
BedrockToolResultContentBlock(image=_block["image"])
)
message.get("name", "")
id = str(message.get("tool_call_id", str(uuid.uuid4())))

View file

@ -719,6 +719,7 @@ class ModelResponseIterator:
content_block_start=content_block_start,
provider_specific_fields=provider_specific_fields,
)
elif content_block_start["content_block"]["type"].endswith("_tool_result"):
# Handle all tool result types (web_search, bash_code_execution, text_editor, etc.)
content_type = content_block_start["content_block"]["type"]
@ -734,6 +735,16 @@ class ModelResponseIterator:
provider_specific_fields["web_search_results"] = (
self.web_search_results
)
elif content_type == "web_fetch_tool_result":
# Capture web_fetch_tool_result for multi-turn reconstruction
# The full content comes in content_block_start, not in deltas
# Fixes: https://github.com/BerriAI/litellm/issues/18137
self.web_search_results.append(
content_block_start["content_block"]
)
provider_specific_fields["web_search_results"] = (
self.web_search_results
)
elif content_type != "tool_search_tool_result":
# Handle other tool results (code execution, etc.)
# Skip tool_search_tool_result as it's internal metadata
@ -741,6 +752,7 @@ class ModelResponseIterator:
self.tool_results = []
self.tool_results.append(content_block_start["content_block"])
provider_specific_fields["tool_results"] = self.tool_results
elif type_chunk == "content_block_stop":
ContentBlockStop(**chunk) # type: ignore
# check if tool call content block - only for tool_use and server_tool_use blocks

View file

@ -1154,6 +1154,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
index=idx,
)
tool_calls.append(tool_call)
## TOOL RESULTS - handle all tool result types (code execution, etc.)
elif content["type"].endswith("_tool_result"):
# Skip tool_search_tool_result as it's internal metadata
@ -1164,11 +1165,16 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
if web_search_results is None:
web_search_results = []
web_search_results.append(content)
elif content["type"] == "web_fetch_tool_result":
if web_search_results is None:
web_search_results = []
web_search_results.append(content)
else:
# All other tool results (bash_code_execution_tool_result, text_editor_code_execution_tool_result, etc.)
if tool_results is None:
tool_results = []
tool_results.append(content)
elif content.get("thinking", None) is not None:
if thinking_blocks is None:
thinking_blocks = []

View file

@ -14,6 +14,10 @@ from typing import (
from openai.types.chat.chat_completion_chunk import Choice as OpenAIStreamingChoice
from litellm.litellm_core_utils.prompt_templates.common_utils import (
parse_tool_call_arguments,
)
from litellm.types.llms.anthropic import (
AllAnthropicToolsValues,
AnthopicMessagesAssistantMessageParam,
@ -425,15 +429,15 @@ class LiteLLMAnthropicMessagesAdapter:
) -> Optional[str]:
"""
Translate Anthropic's thinking parameter to OpenAI's reasoning_effort.
Anthropic thinking format: {'type': 'enabled'|'disabled', 'budget_tokens': int}
OpenAI reasoning_effort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'default'
"""
if not isinstance(thinking, dict):
return None
thinking_type = thinking.get("type", "disabled")
if thinking_type == "disabled":
return None
elif thinking_type == "enabled":
@ -446,7 +450,7 @@ class LiteLLMAnthropicMessagesAdapter:
return "low"
else:
return "minimal"
return None
def translate_anthropic_tool_choice_to_openai(
@ -676,10 +680,10 @@ class LiteLLMAnthropicMessagesAdapter:
type="tool_use",
id=tool_call.id,
name=tool_call.function.name or "",
input=(
json.loads(tool_call.function.arguments)
if tool_call.function.arguments
else {}
input=parse_tool_call_arguments(
tool_call.function.arguments,
tool_name=tool_call.function.name,
context="Anthropic pass-through adapter",
),
)
# Add provider_specific_fields if signature is present

View file

@ -74,6 +74,41 @@ class BaseAWSLLM:
"aws_external_id",
]
def _get_ssl_verify(self):
"""
Get SSL verification setting for boto3 clients.
This ensures that custom CA certificates are properly used for all AWS API calls,
including STS and Bedrock services.
Returns:
Union[bool, str]: SSL verification setting - False to disable, True to enable,
or a string path to a CA bundle file
"""
import litellm
from litellm.secret_managers.main import str_to_bool
# Check environment variable first (highest priority)
ssl_verify = os.getenv("SSL_VERIFY", litellm.ssl_verify)
# Convert string "False"/"True" to boolean
if isinstance(ssl_verify, str):
# Check if it's a file path
if os.path.exists(ssl_verify):
return ssl_verify
# Otherwise try to convert to boolean
ssl_verify_bool = str_to_bool(ssl_verify)
if ssl_verify_bool is not None:
ssl_verify = ssl_verify_bool
# Check SSL_CERT_FILE environment variable for custom CA bundle
if ssl_verify is True or ssl_verify == "True":
ssl_cert_file = os.getenv("SSL_CERT_FILE")
if ssl_cert_file and os.path.exists(ssl_cert_file):
return ssl_cert_file
return ssl_verify
def get_cache_key(self, credential_args: Dict[str, Optional[str]]) -> str:
"""
Generate a unique cache key based on the credential arguments.
@ -569,6 +604,7 @@ class BaseAWSLLM:
"sts",
region_name=aws_region_name,
endpoint_url=sts_endpoint,
verify=self._get_ssl_verify(),
)
# https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRoleWithWebIdentity.html
@ -625,7 +661,7 @@ class BaseAWSLLM:
# Create an STS client without credentials
with tracer.trace("boto3.client(sts) for manual IRSA"):
sts_client = boto3.client("sts", region_name=region)
sts_client = boto3.client("sts", region_name=region, verify=self._get_ssl_verify())
# Manually assume the IRSA role with the session name
verbose_logger.debug(
@ -648,6 +684,7 @@ class BaseAWSLLM:
aws_access_key_id=irsa_creds["AccessKeyId"],
aws_secret_access_key=irsa_creds["SecretAccessKey"],
aws_session_token=irsa_creds["SessionToken"],
verify=self._get_ssl_verify(),
)
# Get current caller identity for debugging
@ -686,7 +723,7 @@ class BaseAWSLLM:
verbose_logger.debug("Same account role assumption, using automatic IRSA")
with tracer.trace("boto3.client(sts) with automatic IRSA"):
sts_client = boto3.client("sts", region_name=region)
sts_client = boto3.client("sts", region_name=region, verify=self._get_ssl_verify())
# Get current caller identity for debugging
try:
@ -809,7 +846,7 @@ class BaseAWSLLM:
# This allows the web identity token to work automatically
if aws_access_key_id is None and aws_secret_access_key is None:
with tracer.trace("boto3.client(sts)"):
sts_client = boto3.client("sts")
sts_client = boto3.client("sts", verify=self._get_ssl_verify())
else:
with tracer.trace("boto3.client(sts)"):
sts_client = boto3.client(
@ -817,6 +854,7 @@ class BaseAWSLLM:
aws_access_key_id=aws_access_key_id,
aws_secret_access_key=aws_secret_access_key,
aws_session_token=aws_session_token,
verify=self._get_ssl_verify(),
)
assume_role_params = {

View file

@ -260,7 +260,7 @@ def init_bedrock_client(
status_code=401,
)
sts_client = boto3.client("sts")
sts_client = boto3.client("sts", verify=ssl_verify)
# https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRoleWithWebIdentity.html
# https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/sts/client/assume_role_with_web_identity.html

View file

@ -142,6 +142,7 @@ class BedrockFilesHandler(BaseAWSLLM):
aws_secret_access_key=credentials.secret_key,
aws_session_token=credentials.token,
region_name=aws_region_name,
verify=self._get_ssl_verify(),
)
# Download file from S3

View file

@ -24,6 +24,37 @@ class BedrockPassthroughConfig(
def is_streaming_request(self, endpoint: str, request_data: dict) -> bool:
return "stream" in endpoint
def _encode_model_id_for_endpoint(self, model_id: str) -> str:
"""
Encode model_id (especially ARNs) for use in Bedrock endpoints.
ARNs contain special characters like colons and slashes that need to be
properly URL-encoded when used in HTTP request paths. For example:
arn:aws:bedrock:us-east-1:123:application-inference-profile/abc123
becomes:
arn:aws:bedrock:us-east-1:123:application-inference-profile%2Fabc123
Args:
model_id: The model ID or ARN to encode
Returns:
The encoded model_id suitable for use in endpoint URLs
"""
from litellm.passthrough.utils import CommonUtils
import re
# Create a temporary endpoint with the model_id to check if encoding is needed
temp_endpoint = f"/model/{model_id}/converse"
encoded_temp_endpoint = CommonUtils.encode_bedrock_runtime_modelid_arn(temp_endpoint)
# Extract the encoded model_id from the temporary endpoint
encoded_model_id_match = re.search(r'/model/([^/]+)/', encoded_temp_endpoint)
if encoded_model_id_match:
return encoded_model_id_match.group(1)
else:
# Fallback to original model_id if extraction fails
return model_id
def get_complete_url(
self,
api_base: Optional[str],
@ -53,9 +84,13 @@ class BedrockPassthroughConfig(
# If model_id is provided (e.g., Application Inference Profile ARN), use it in the endpoint
# instead of the translated model name
if model_id is not None:
# Replace the model name in the endpoint with the model_id
import re
endpoint = re.sub(r'model/[^/]+/', f'model/{model_id}/', endpoint)
# Encode the model_id if it's an ARN to properly handle special characters
encoded_model_id = self._encode_model_id_for_endpoint(model_id)
# Replace the model name in the endpoint with the encoded model_id
endpoint = re.sub(r'model/[^/]+/', f'model/{encoded_model_id}/', endpoint)
return self.format_url(endpoint, endpoint_url, request_query_params or {}), endpoint_url
def sign_request(

View file

@ -87,6 +87,7 @@ class GoogleAIStudioGeminiConfig(VertexGeminiConfig):
"stop",
"logprobs",
"frequency_penalty",
"presence_penalty",
"modalities",
"parallel_tool_calls",
"web_search_options",

View file

@ -1124,8 +1124,11 @@ def adapt_messages_to_generic_oci_standard_content_message(
elif type == "image_url":
image_url = content_item.get("image_url")
# Handle both OpenAI format (object with url) and string format
if isinstance(image_url, dict):
image_url = image_url.get("url")
if not isinstance(image_url, str):
raise Exception("Prop `image_url` is not a string")
raise Exception("Prop `image_url` must be a string or an object with a `url` property")
new_content.append(OCIImageContentPart(imageUrl=image_url))
return OCIMessage(

View file

@ -310,9 +310,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
"""
return Tools(googleSearch={})
def _transform_computer_use_config(
self, computer_use_config: dict
) -> dict:
def _transform_computer_use_config(self, computer_use_config: dict) -> dict:
"""
Transform Computer Use configuration to Gemini API format.
@ -323,7 +321,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
Transformed computer use configuration for Gemini API
"""
transformed_config = {}
# Transform environment values if needed
if "environment" in computer_use_config:
env_value = computer_use_config["environment"]
@ -339,13 +337,17 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
f"Invalid environment value for computer_use: {env_value}. "
f"Supported: 'browser', 'unspecified', 'ENVIRONMENT_BROWSER', 'ENVIRONMENT_UNSPECIFIED'"
)
# Transform excluded_predefined_functions to camelCase
if "excluded_predefined_functions" in computer_use_config:
transformed_config["excludedPredefinedFunctions"] = computer_use_config["excluded_predefined_functions"]
transformed_config["excludedPredefinedFunctions"] = computer_use_config[
"excluded_predefined_functions"
]
elif "excludedPredefinedFunctions" in computer_use_config:
transformed_config["excludedPredefinedFunctions"] = computer_use_config["excludedPredefinedFunctions"]
transformed_config["excludedPredefinedFunctions"] = computer_use_config[
"excludedPredefinedFunctions"
]
return transformed_config
def _extract_google_maps_retrieval_config(
@ -446,9 +448,9 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
value = _remove_strict_from_schema(value)
for tool in value:
openai_function_object: Optional[
ChatCompletionToolParamFunctionChunk
] = None
openai_function_object: Optional[ChatCompletionToolParamFunctionChunk] = (
None
)
if "function" in tool: # tools list
_openai_function_object = ChatCompletionToolParamFunctionChunk( # type: ignore
**tool["function"]
@ -553,7 +555,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
"Invalid tool={}. Use `litellm.set_verbose` or `litellm --detailed_debug` to see raw request."
)
# Build list of Tool objects - each Tool should contain exactly one type
# Build list of Tool objects - each Tool should contain exactly one type
# per Vertex AI API spec: "A Tool object should contain exactly one type of Tool"
_tools_list: List[Tools] = []
@ -570,11 +572,15 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
_tools_list.append(search_tool)
if googleSearchRetrieval is not None:
retrieval_tool = Tools()
retrieval_tool[VertexToolName.GOOGLE_SEARCH_RETRIEVAL.value] = googleSearchRetrieval
retrieval_tool[VertexToolName.GOOGLE_SEARCH_RETRIEVAL.value] = (
googleSearchRetrieval
)
_tools_list.append(retrieval_tool)
if enterpriseWebSearch is not None:
enterprise_tool = Tools()
enterprise_tool[VertexToolName.ENTERPRISE_WEB_SEARCH.value] = enterpriseWebSearch
enterprise_tool[VertexToolName.ENTERPRISE_WEB_SEARCH.value] = (
enterpriseWebSearch
)
_tools_list.append(enterprise_tool)
if code_execution is not None:
code_tool = Tools()
@ -593,7 +599,6 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
computer_tool[VertexToolName.COMPUTER_USE.value] = computerUse
_tools_list.append(computer_tool)
# Add retrieval config to toolConfig if googleMaps has location data
if google_maps_retrieval_config is not None:
if "toolConfig" not in optional_params:
@ -710,8 +715,9 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
GeminiThinkingConfig with thinkingLevel and includeThoughts
"""
# Check if this is gemini-3-flash which supports MINIMAL thinking level
is_gemini3flash= model and (
"gemini-3-flash-preview" in model.lower() or "gemini-3-flash" in model.lower()
is_gemini3flash = model and (
"gemini-3-flash-preview" in model.lower()
or "gemini-3-flash" in model.lower()
)
if reasoning_effort == "minimal":
if is_gemini3flash:
@ -799,7 +805,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
thinking_budget = thinking_param.get("budget_tokens")
params: GeminiThinkingConfig = {}
# For Gemini 3+ models, use thinkingLevel instead of thinkingBudget
if model and VertexGeminiConfig._is_gemini_3_or_newer(model):
if thinking_enabled:
@ -808,11 +814,21 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
else:
params["includeThoughts"] = True
if thinking_budget >= 10000:
is_gemini3flash = "gemini-3-flash-preview" in model.lower() or "gemini-3-flash" in model.lower()
params["thinkingLevel"] = "minimal" if is_gemini3flash else "low"
is_gemini3flash = (
"gemini-3-flash-preview" in model.lower()
or "gemini-3-flash" in model.lower()
)
params["thinkingLevel"] = (
"minimal" if is_gemini3flash else "low"
)
else:
is_gemini3flash = "gemini-3-flash-preview" in model.lower() or "gemini-3-flash" in model.lower()
params["thinkingLevel"] = "minimal" if is_gemini3flash else "low"
is_gemini3flash = (
"gemini-3-flash-preview" in model.lower()
or "gemini-3-flash" in model.lower()
)
params["thinkingLevel"] = (
"minimal" if is_gemini3flash else "low"
)
else:
# Thinking disabled
params["includeThoughts"] = False
@ -824,7 +840,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
params["includeThoughts"] = True
if thinking_budget is not None and isinstance(thinking_budget, int):
params["thinkingBudget"] = thinking_budget
return params
def map_response_modalities(self, value: list) -> list:
@ -980,16 +996,16 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
param_description="thinking_budget",
)
if VertexGeminiConfig._is_gemini_3_or_newer(model):
optional_params[
"thinkingConfig"
] = VertexGeminiConfig._map_reasoning_effort_to_thinking_level(
value, model
optional_params["thinkingConfig"] = (
VertexGeminiConfig._map_reasoning_effort_to_thinking_level(
value, model
)
)
else:
optional_params[
"thinkingConfig"
] = VertexGeminiConfig._map_reasoning_effort_to_thinking_budget(
value, model
optional_params["thinkingConfig"] = (
VertexGeminiConfig._map_reasoning_effort_to_thinking_budget(
value, model
)
)
elif param == "thinking":
# Validate no conflict with thinking_level
@ -998,11 +1014,11 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
param_name="thinking",
param_description="thinking_budget",
)
optional_params[
"thinkingConfig"
] = VertexGeminiConfig._map_thinking_param(
cast(AnthropicThinkingParam, value),
model=model,
optional_params["thinkingConfig"] = (
VertexGeminiConfig._map_thinking_param(
cast(AnthropicThinkingParam, value),
model=model,
)
)
elif param == "modalities" and isinstance(value, list):
response_modalities = self.map_response_modalities(value)
@ -1036,8 +1052,13 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
):
# For gemini-3-flash-preview, default to "minimal" to match Gemini 2.5 Flash behavior
# For other Gemini 3 models, default to "low"
is_gemini3flash = "gemini-3-flash-preview" in model.lower() or "gemini-3-flash" in model.lower()
thinking_config["thinkingLevel"] = "minimal" if is_gemini3flash else "low"
is_gemini3flash = (
"gemini-3-flash-preview" in model.lower()
or "gemini-3-flash" in model.lower()
)
thinking_config["thinkingLevel"] = (
"minimal" if is_gemini3flash else "low"
)
optional_params["thinkingConfig"] = thinking_config
return optional_params
@ -1226,7 +1247,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
block: ChatCompletionThinkingBlock = {
"type": "thinking",
"thinking": thinking_text,
}
}
signature = part.get("thoughtSignature")
if signature is not None:
block["signature"] = signature
@ -1360,10 +1381,10 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
_tool_response_chunk["provider_specific_fields"] = { # type: ignore
"thought_signature": thought_signature
}
_tool_response_chunk[
"id"
] = _encode_tool_call_id_with_signature(
_tool_response_chunk["id"] or "", thought_signature
_tool_response_chunk["id"] = (
_encode_tool_call_id_with_signature(
_tool_response_chunk["id"] or "", thought_signature
)
)
_tools.append(_tool_response_chunk)
cumulative_tool_call_idx += 1
@ -1551,13 +1572,18 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
elif modality == "IMAGE":
response_tokens_details.image_tokens = token_count
# Calculate text_tokens if not explicitly provided in candidatesTokensDetails
# candidatesTokenCount includes all modalities, so: text = total - (image + audio)
# Calculate text_tokens if not explicitly provided in candidatesTokensDetails
# candidatesTokenCount includes all modalities, so: text = total - (image + audio)
candidates_token_count = usage_metadata.get("candidatesTokenCount", 0)
if candidates_token_count > 0:
if response_tokens_details is None:
response_tokens_details = CompletionTokensDetailsWrapper()
if response_tokens_details.text_tokens is None:
candidates_token_count = usage_metadata.get("candidatesTokenCount", 0)
image_tokens = response_tokens_details.image_tokens or 0
audio_tokens_candidate = response_tokens_details.audio_tokens or 0
calculated_text_tokens = candidates_token_count - image_tokens - audio_tokens_candidate
calculated_text_tokens = (
candidates_token_count - image_tokens - audio_tokens_candidate
)
response_tokens_details.text_tokens = calculated_text_tokens
#########################################################
@ -2076,28 +2102,28 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
## ADD METADATA TO RESPONSE ##
setattr(model_response, "vertex_ai_grounding_metadata", grounding_metadata)
model_response._hidden_params[
"vertex_ai_grounding_metadata"
] = grounding_metadata
model_response._hidden_params["vertex_ai_grounding_metadata"] = (
grounding_metadata
)
setattr(
model_response, "vertex_ai_url_context_metadata", url_context_metadata
)
model_response._hidden_params[
"vertex_ai_url_context_metadata"
] = url_context_metadata
model_response._hidden_params["vertex_ai_url_context_metadata"] = (
url_context_metadata
)
setattr(model_response, "vertex_ai_safety_results", safety_ratings)
model_response._hidden_params[
"vertex_ai_safety_results"
] = safety_ratings # older approach - maintaining to prevent regressions
model_response._hidden_params["vertex_ai_safety_results"] = (
safety_ratings # older approach - maintaining to prevent regressions
)
## ADD CITATION METADATA ##
setattr(model_response, "vertex_ai_citation_metadata", citation_metadata)
model_response._hidden_params[
"vertex_ai_citation_metadata"
] = citation_metadata # older approach - maintaining to prevent regressions
model_response._hidden_params["vertex_ai_citation_metadata"] = (
citation_metadata # older approach - maintaining to prevent regressions
)
except Exception as e:
raise VertexAIError(

View file

@ -23340,13 +23340,13 @@
"supports_tool_choice": true
},
"openrouter/openai/gpt-oss-20b": {
"input_cost_per_token": 1.8e-07,
"input_cost_per_token": 2e-08,
"litellm_provider": "openrouter",
"max_input_tokens": 131072,
"max_output_tokens": 32768,
"max_tokens": 32768,
"mode": "chat",
"output_cost_per_token": 8e-07,
"output_cost_per_token": 1e-07,
"source": "https://openrouter.ai/openai/gpt-oss-20b",
"supports_function_calling": true,
"supports_parallel_function_calling": true,

View file

@ -23340,13 +23340,13 @@
"supports_tool_choice": true
},
"openrouter/openai/gpt-oss-20b": {
"input_cost_per_token": 1.8e-07,
"input_cost_per_token": 2e-08,
"litellm_provider": "openrouter",
"max_input_tokens": 131072,
"max_output_tokens": 32768,
"max_tokens": 32768,
"mode": "chat",
"output_cost_per_token": 8e-07,
"output_cost_per_token": 1e-07,
"source": "https://openrouter.ai/openai/gpt-oss-20b",
"supports_function_calling": true,
"supports_parallel_function_calling": true,

View file

@ -197,3 +197,90 @@ class TestOCIGetCompleteUrl:
assert "eu-frankfurt-1" in url
assert "inference.generativeai" in url
class TestOCIImageUrlTransformation:
"""Tests for OCI image_url format handling in multimodal messages.
Fixes: https://github.com/BerriAI/litellm/issues/18270
"""
def test_image_url_as_string(self):
"""Test that image_url as a plain string works."""
from litellm.llms.oci.chat.transformation import adapt_messages_to_generic_oci_standard
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "What is in this image?"},
{"type": "image_url", "image_url": "https://example.com/image.png"},
],
}
]
result = adapt_messages_to_generic_oci_standard(messages)
assert len(result) == 1
assert result[0].role == "USER"
assert len(result[0].content) == 2
assert result[0].content[1].imageUrl == "https://example.com/image.png"
def test_image_url_as_openai_object(self):
"""Test that image_url as OpenAI-style object {"url": "..."} works."""
from litellm.llms.oci.chat.transformation import adapt_messages_to_generic_oci_standard
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "What is in this image?"},
{"type": "image_url", "image_url": {"url": "https://example.com/image.png"}},
],
}
]
result = adapt_messages_to_generic_oci_standard(messages)
assert len(result) == 1
assert result[0].role == "USER"
assert len(result[0].content) == 2
assert result[0].content[1].imageUrl == "https://example.com/image.png"
def test_image_url_invalid_type_raises_error(self):
"""Test that invalid image_url type raises an error."""
from litellm.llms.oci.chat.transformation import adapt_messages_to_generic_oci_standard
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "What is in this image?"},
{"type": "image_url", "image_url": 12345}, # Invalid type
],
}
]
with pytest.raises(Exception) as exc_info:
adapt_messages_to_generic_oci_standard(messages)
assert "image_url" in str(exc_info.value)
def test_image_url_object_missing_url_raises_error(self):
"""Test that object without 'url' property raises an error."""
from litellm.llms.oci.chat.transformation import adapt_messages_to_generic_oci_standard
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "What is in this image?"},
{"type": "image_url", "image_url": {"detail": "high"}}, # Missing 'url'
],
}
]
with pytest.raises(Exception) as exc_info:
adapt_messages_to_generic_oci_standard(messages)
assert "image_url" in str(exc_info.value)

View file

@ -7,11 +7,10 @@ import pytest
sys.path.insert(0, os.path.abspath("../.."))
from typing import Union, List
from typing import List
# from litellm.litellm_core_utils.prompt_templates.factory import prompt_factory
import litellm
from litellm import completion
from litellm.litellm_core_utils.prompt_templates.factory import (
_bedrock_tools_pt,
anthropic_messages_pt,
@ -31,7 +30,7 @@ from litellm.llms.vertex_ai.gemini.transformation import (
_gemini_convert_messages_with_history,
)
from litellm.types.llms.openai import AllMessageValues
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import MagicMock, patch
def test_llama_3_prompt():
@ -129,10 +128,6 @@ def test_anthropic_pt_formatting():
def test_anthropic_messages_nested_pt():
from litellm.types.llms.anthropic import (
AnthopicMessagesAssistantMessageParam,
AnthropicMessagesUserMessageParam,
)
messages = [
{"content": [{"text": "here is a task", "type": "text"}], "role": "user"},
@ -214,7 +209,7 @@ def test_create_anthropic_image_param_with_http_url():
image_param = create_anthropic_image_param(
"https://example.com/image.jpg", format=None
)
assert image_param["type"] == "image"
assert image_param["source"]["type"] == "url"
assert image_param["source"]["url"] == "https://example.com/image.jpg"
@ -225,7 +220,7 @@ def test_create_anthropic_image_param_with_https_url():
image_param = create_anthropic_image_param(
"https://example.com/image.png", format=None
)
assert image_param["type"] == "image"
assert image_param["source"]["type"] == "url"
assert image_param["source"]["url"] == "https://example.com/image.png"
@ -236,7 +231,7 @@ def test_create_anthropic_image_param_with_dict_input():
image_param = create_anthropic_image_param(
{"url": "https://example.com/image.jpg", "format": "image/jpeg"}, format=None
)
assert image_param["type"] == "image"
assert image_param["source"]["type"] == "url"
assert image_param["source"]["url"] == "https://example.com/image.jpg"
@ -247,7 +242,7 @@ def test_create_anthropic_image_param_with_base64_data_uri():
image_param = create_anthropic_image_param(
"data:image/jpeg;base64,/9j/4AAQSkZJRg==", format=None
)
assert image_param["type"] == "image"
assert image_param["source"]["type"] == "base64"
assert image_param["source"]["media_type"] == "image/jpeg"
@ -259,7 +254,7 @@ def test_create_anthropic_image_param_with_format_override():
image_param = create_anthropic_image_param(
"data:image/jpeg;base64,1234", format="image/png"
)
assert image_param["type"] == "image"
assert image_param["source"]["type"] == "base64"
assert image_param["source"]["media_type"] == "image/png"
@ -279,19 +274,19 @@ def test_anthropic_messages_pt_with_url_image():
],
}
]
result = anthropic_messages_pt(
messages=messages, model="claude-3-5-sonnet", llm_provider="anthropic"
)
assert len(result) == 1
assert result[0]["role"] == "user"
assert isinstance(result[0]["content"], list)
assert len(result[0]["content"]) == 2
# Check text content
assert result[0]["content"][0]["type"] == "text"
# Check image content - should be URL reference, not base64
assert result[0]["content"][1]["type"] == "image"
assert result[0]["content"][1]["source"]["type"] == "url"
@ -312,16 +307,16 @@ def test_anthropic_messages_pt_with_base64_image():
],
}
]
result = anthropic_messages_pt(
messages=messages, model="claude-3-5-sonnet", llm_provider="anthropic"
)
assert len(result) == 1
assert result[0]["role"] == "user"
assert isinstance(result[0]["content"], list)
assert len(result[0]["content"]) == 2
# Check image content - should be base64, not URL
assert result[0]["content"][1]["type"] == "image"
assert result[0]["content"][1]["source"]["type"] == "base64"
@ -568,7 +563,9 @@ def test_vertex_only_image_user_message():
},
]
response = _gemini_convert_messages_with_history(messages=messages, model="gemini-1.5-pro")
response = _gemini_convert_messages_with_history(
messages=messages, model="gemini-1.5-pro"
)
expected_response = [
{
@ -962,8 +959,8 @@ def test_convert_to_anthropic_tool_invoke_regular_tool():
"type": "function",
"function": {
"name": "get_weather",
"arguments": '{"location": "San Francisco"}'
}
"arguments": '{"location": "San Francisco"}',
},
}
]
@ -979,7 +976,7 @@ def test_convert_to_anthropic_tool_invoke_regular_tool():
def test_convert_to_anthropic_tool_invoke_server_tool():
"""
Test that server_tool_use (srvtoolu_) is reconstructed as server_tool_use.
Fixes: https://github.com/BerriAI/litellm/issues/17737
"""
tool_calls = [
@ -988,8 +985,8 @@ def test_convert_to_anthropic_tool_invoke_server_tool():
"type": "function",
"function": {
"name": "web_search",
"arguments": '{"query": "elephant weight"}'
}
"arguments": '{"query": "elephant weight"}',
},
}
]
@ -1005,7 +1002,7 @@ def test_convert_to_anthropic_tool_invoke_server_tool():
def test_convert_to_anthropic_tool_invoke_with_web_search_results():
"""
Test that web_search_tool_result is included after server_tool_use.
Fixes: https://github.com/BerriAI/litellm/issues/17737
"""
tool_calls = [
@ -1014,8 +1011,8 @@ def test_convert_to_anthropic_tool_invoke_with_web_search_results():
"type": "function",
"function": {
"name": "web_search",
"arguments": '{"query": "elephant weight"}'
}
"arguments": '{"query": "elephant weight"}',
},
}
]
@ -1028,13 +1025,15 @@ def test_convert_to_anthropic_tool_invoke_with_web_search_results():
"type": "web_search_result",
"url": "https://example.com",
"title": "Elephant Facts",
"snippet": "Elephants weigh 5000 kg"
"snippet": "Elephants weigh 5000 kg",
}
]
],
}
]
result = convert_to_anthropic_tool_invoke(tool_calls, web_search_results=web_search_results)
result = convert_to_anthropic_tool_invoke(
tool_calls, web_search_results=web_search_results
)
assert len(result) == 2
# First: server_tool_use
@ -1048,7 +1047,7 @@ def test_convert_to_anthropic_tool_invoke_with_web_search_results():
def test_convert_to_anthropic_tool_invoke_mixed_tools():
"""
Test that mixed server and regular tools are reconstructed correctly.
Fixes: https://github.com/BerriAI/litellm/issues/17737
"""
tool_calls = [
@ -1057,28 +1056,27 @@ def test_convert_to_anthropic_tool_invoke_mixed_tools():
"type": "function",
"function": {
"name": "web_search",
"arguments": '{"query": "elephant weight"}'
}
"arguments": '{"query": "elephant weight"}',
},
},
{
"id": "toolu_01XYZ789",
"type": "function",
"function": {
"name": "add_numbers",
"arguments": '{"a": 5000, "b": 100}'
}
}
"function": {"name": "add_numbers", "arguments": '{"a": 5000, "b": 100}'},
},
]
web_search_results = [
{
"type": "web_search_tool_result",
"tool_use_id": "srvtoolu_01ABC123",
"content": [{"url": "https://example.com", "title": "Test"}]
"content": [{"url": "https://example.com", "title": "Test"}],
}
]
result = convert_to_anthropic_tool_invoke(tool_calls, web_search_results=web_search_results)
result = convert_to_anthropic_tool_invoke(
tool_calls, web_search_results=web_search_results
)
assert len(result) == 3
# First: server_tool_use
@ -1094,7 +1092,7 @@ def test_convert_to_anthropic_tool_invoke_mixed_tools():
def test_anthropic_messages_pt_with_server_tool_use():
"""
Test that anthropic_messages_pt correctly reconstructs server_tool_use from provider_specific_fields.
Fixes: https://github.com/BerriAI/litellm/issues/17737
"""
messages = [
@ -1108,36 +1106,40 @@ def test_anthropic_messages_pt_with_server_tool_use():
"type": "function",
"function": {
"name": "web_search",
"arguments": '{"query": "elephant weight"}'
}
"arguments": '{"query": "elephant weight"}',
},
},
{
"id": "toolu_01XYZ789",
"type": "function",
"function": {
"name": "add_numbers",
"arguments": '{"a": 5000, "b": 100}'
}
}
"arguments": '{"a": 5000, "b": 100}',
},
},
],
"provider_specific_fields": {
"web_search_results": [
{
"type": "web_search_tool_result",
"tool_use_id": "srvtoolu_01ABC123",
"content": [{"url": "https://example.com", "title": "Test", "snippet": "5000 kg"}]
"content": [
{
"url": "https://example.com",
"title": "Test",
"snippet": "5000 kg",
}
],
}
]
}
},
},
{
"role": "tool",
"tool_call_id": "toolu_01XYZ789",
"content": "5100"
}
{"role": "tool", "tool_call_id": "toolu_01XYZ789", "content": "5100"},
]
result = anthropic_messages_pt(messages, model="claude-sonnet-4-5", llm_provider="anthropic")
result = anthropic_messages_pt(
messages, model="claude-sonnet-4-5", llm_provider="anthropic"
)
# Find the assistant message
assistant_msg = next(m for m in result if m["role"] == "assistant")
@ -1162,3 +1164,73 @@ def test_anthropic_messages_pt_with_server_tool_use():
# Verify regular tool_use
tool_use = next(c for c in content if c.get("type") == "tool_use")
assert tool_use["id"] == "toolu_01XYZ789"
# ============ parse_tool_call_arguments Tests ============
# Tests for the shared utility that parses tool call JSON arguments
def test_parse_tool_call_arguments_valid_json():
"""Test that valid JSON is parsed correctly."""
from litellm.litellm_core_utils.prompt_templates.common_utils import (
parse_tool_call_arguments,
)
result = parse_tool_call_arguments('{"city": "Paris", "units": "celsius"}')
assert result == {"city": "Paris", "units": "celsius"}
def test_parse_tool_call_arguments_empty_input():
"""Test that None/empty input returns empty dict."""
from litellm.litellm_core_utils.prompt_templates.common_utils import (
parse_tool_call_arguments,
)
assert parse_tool_call_arguments(None) == {}
assert parse_tool_call_arguments("") == {}
def test_parse_tool_call_arguments_malformed_json():
"""Test that malformed JSON raises ValueError with context."""
from litellm.litellm_core_utils.prompt_templates.common_utils import (
parse_tool_call_arguments,
)
with pytest.raises(ValueError) as exc_info:
parse_tool_call_arguments(
'{"skill_name": "pptx',
tool_name="load_skill",
context="Anthropic tool invoke",
)
error_msg = str(exc_info.value)
assert "load_skill" in error_msg
assert "Anthropic tool invoke" in error_msg
assert '{"skill_name": "pptx' in error_msg
assert "Unterminated string" in error_msg
def test_convert_to_anthropic_tool_invoke_malformed_json():
"""
Test that convert_to_anthropic_tool_invoke raises ValueError with context
when tool arguments contain malformed JSON.
Fixes: https://github.com/BerriAI/litellm/issues/18920
"""
tool_calls = [
{
"id": "toolu_01_invalid",
"type": "function",
"function": {
"name": "bad_tool",
"arguments": '{"truncated', # Malformed JSON
},
}
]
with pytest.raises(ValueError) as exc_info:
convert_to_anthropic_tool_invoke(tool_calls)
error_msg = str(exc_info.value)
assert "bad_tool" in error_msg
assert '{"truncated' in error_msg

View file

@ -1120,12 +1120,13 @@ async def test_google_generate_content_with_openai():
# Print the response for verification
print(f"Response: {response}")
#########################################################
#########################################################
# validate only expected fields were sent to litellm.completion
passed_fields = set(call_kwargs.keys())
# remove any GenericLiteLLMParams fields
passed_fields = passed_fields - set(GenericLiteLLMParams.model_fields.keys())
assert passed_fields == set(["model", "messages"]), f"Expected only model and messages to be passed through, got {passed_fields}"
# extra_headers is now explicitly passed through for providers that need custom headers
assert passed_fields == set(["model", "messages", "extra_headers"]), f"Expected model, messages, and extra_headers to be passed through, got {passed_fields}"
@pytest.mark.asyncio
async def test_agenerate_content_x_goog_api_key_header():
"""

View file

@ -252,7 +252,7 @@ def test_stream_transformation_error_handling():
def test_non_stream_response_when_stream_requested():
"""Test handling of non-stream responses when streaming was requested"""
from litellm.types.utils import Choices
# Mock a non-stream response (ModelResponse with valid choices)
mock_response = ModelResponse(
id="test-123",
@ -270,13 +270,13 @@ def test_non_stream_response_when_stream_requested():
model="gpt-3.5-turbo",
object="chat.completion"
)
# Create an instance of the adapter
adapter = GoogleGenAIAdapter()
# Test the adapter's translate_completion_to_generate_content method directly
result = adapter.translate_completion_to_generate_content(mock_response)
# Verify the result is a valid Google GenAI format response
assert "candidates" in result
assert isinstance(result["candidates"], list)
@ -287,4 +287,70 @@ def test_non_stream_response_when_stream_requested():
assert isinstance(candidate["content"]["parts"], list)
assert len(candidate["content"]["parts"]) > 0
assert "text" in candidate["content"]["parts"][0]
assert candidate["content"]["parts"][0]["text"] == "Hello, world!"
assert candidate["content"]["parts"][0]["text"] == "Hello, world!"
def test_extra_headers_forwarding():
"""Test that extra_headers is correctly forwarded to completion call.
This is important for providers like github_copilot that require custom
headers (e.g., Editor-Version) for authentication.
"""
# Test that extra_headers is included in completion kwargs
model = "gpt-3.5-turbo"
contents = {"role": "user", "parts": [{"text": "Test"}]}
config = {"temperature": 0.7}
extra_kwargs = {
"extra_headers": {
"Editor-Version": "vscode/1.95.0",
"Editor-Plugin-Version": "copilot-chat/0.22.4",
"Custom-Header": "custom-value"
},
"metadata": {"user_id": "test-user"}
}
completion_kwargs = GenerateContentToCompletionHandler._prepare_completion_kwargs(
model=model,
contents=contents,
config=config,
stream=False,
extra_kwargs=extra_kwargs
)
# Verify extra_headers is forwarded
assert "extra_headers" in completion_kwargs, "extra_headers should be forwarded to completion call"
assert completion_kwargs["extra_headers"]["Editor-Version"] == "vscode/1.95.0"
assert completion_kwargs["extra_headers"]["Editor-Plugin-Version"] == "copilot-chat/0.22.4"
assert completion_kwargs["extra_headers"]["Custom-Header"] == "custom-value"
# Verify metadata is also forwarded (existing behavior)
assert "metadata" in completion_kwargs
assert completion_kwargs["metadata"]["user_id"] == "test-user"
def test_extra_headers_not_present():
"""Test that missing extra_headers doesn't cause issues."""
model = "gpt-3.5-turbo"
contents = {"role": "user", "parts": [{"text": "Test"}]}
config = {"temperature": 0.7}
# extra_kwargs without extra_headers
extra_kwargs = {
"metadata": {"user_id": "test-user"}
}
completion_kwargs = GenerateContentToCompletionHandler._prepare_completion_kwargs(
model=model,
contents=contents,
config=config,
stream=False,
extra_kwargs=extra_kwargs
)
# Verify extra_headers is not present (no error)
assert "extra_headers" not in completion_kwargs
# Verify metadata is still forwarded
assert "metadata" in completion_kwargs
assert completion_kwargs["metadata"]["user_id"] == "test-user"

View file

@ -473,7 +473,6 @@ def test_partial_json_chunk_accumulation():
streaming_response=MagicMock(), sync_stream=True, json_mode=False
)
# Simulate a complete JSON chunk being split into two parts
partial_chunk_1 = '{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hel'
partial_chunk_2 = 'lo"}}'
@ -781,6 +780,169 @@ def test_web_search_tool_result_captured_in_provider_specific_fields():
), "First result title should match"
def test_web_fetch_tool_result_captured_in_provider_specific_fields():
"""
Test that web_fetch_tool_result content is captured in provider_specific_fields.
This tests the fix for https://github.com/BerriAI/litellm/issues/18137
where streaming with Anthropic web fetch wasn't capturing web_fetch_tool_result
blocks, causing multi-turn conversations to fail.
The web_fetch_tool_result content comes ALL AT ONCE in content_block_start,
not in deltas, so we need to capture it there.
"""
iterator = ModelResponseIterator(
streaming_response=MagicMock(), sync_stream=True, json_mode=False
)
# Simulate the streaming sequence with web_fetch_tool_result
chunks = [
# 1. message_start
{
"type": "message_start",
"message": {
"id": "msg_123",
"type": "message",
"role": "assistant",
"content": [],
"usage": {"input_tokens": 10, "output_tokens": 1},
},
},
# 2. server_tool_use block starts (web_fetch)
{
"type": "content_block_start",
"index": 0,
"content_block": {
"type": "server_tool_use",
"id": "srvtoolu_01ABC123",
"name": "web_fetch",
},
},
# 3. input_json_delta with the url
{
"type": "content_block_delta",
"index": 0,
"delta": {"type": "input_json_delta", "partial_json": '{"url": "https://example.com"}'},
},
# 4. content_block_stop for server_tool_use
{"type": "content_block_stop", "index": 0},
# 5. web_fetch_tool_result block starts - THIS IS WHERE THE RESULTS ARE
{
"type": "content_block_start",
"index": 1,
"content_block": {
"type": "web_fetch_tool_result",
"tool_use_id": "srvtoolu_01ABC123",
"content": {
"type": "web_fetch_result",
"url": "https://example.com",
"retrieved_at": "2025-12-16T19:28:29.758000+00:00",
"content": {
"type": "document",
"source": {
"type": "text",
"media_type": "text/plain",
"data": "Hello World",
},
"title": "Example Page",
},
},
},
},
# 6. content_block_stop for web_fetch_tool_result
{"type": "content_block_stop", "index": 1},
]
web_search_results = None
for chunk in chunks:
parsed = iterator.chunk_parser(chunk)
if (
parsed.choices
and parsed.choices[0].delta.provider_specific_fields
and "web_search_results" in parsed.choices[0].delta.provider_specific_fields
):
web_search_results = parsed.choices[0].delta.provider_specific_fields[
"web_search_results"
]
# Verify web_fetch_tool_result was captured (stored in web_search_results list)
assert web_search_results is not None, "web_search_results should be captured"
assert len(web_search_results) == 1, "Should have 1 web_fetch_tool_result block"
assert (
web_search_results[0]["type"] == "web_fetch_tool_result"
), "Block type should be web_fetch_tool_result"
assert (
web_search_results[0]["tool_use_id"] == "srvtoolu_01ABC123"
), "tool_use_id should match"
assert (
web_search_results[0]["content"]["url"] == "https://example.com"
), "URL should match"
assert (
web_search_results[0]["content"]["content"]["title"] == "Example Page"
), "Title should match"
def test_web_fetch_tool_result_no_extra_tool_calls():
"""
Test that web_fetch_tool_result blocks don't emit tool call chunks.
This tests the fix for https://github.com/BerriAI/litellm/issues/18137
where streaming with Anthropic web fetch was causing issues with tool call arguments.
The issue was that web_fetch_tool_result blocks have input_json_delta events with {}
that were incorrectly being converted to tool calls.
"""
iterator = ModelResponseIterator(
streaming_response=MagicMock(), sync_stream=True, json_mode=False
)
# to verify it doesn't emit tool calls
chunks = [
# 1. web_fetch_tool_result block starts
{
"type": "content_block_start",
"index": 1,
"content_block": {
"type": "web_fetch_tool_result",
"tool_use_id": "srvtoolu_01ABC123",
"content": {
"type": "web_fetch_result",
"url": "https://example.com",
"retrieved_at": "2025-12-16T19:28:29.758000+00:00",
"content": {
"type": "document",
"source": {
"type": "text",
"media_type": "text/plain",
"data": "Hello World",
},
"title": "Example Page",
},
},
},
},
# 2. input_json_delta with {} - this should NOT emit a tool call
{
"type": "content_block_delta",
"index": 1,
"delta": {"type": "input_json_delta", "partial_json": "{}"},
},
# 3. content_block_stop for web_fetch_tool_result
{"type": "content_block_stop", "index": 1},
]
tool_call_count = 0
for chunk in chunks:
parsed = iterator.chunk_parser(chunk)
if parsed.choices and parsed.choices[0].delta.tool_calls:
tool_call_count += 1
# Should have 0 tool calls - web_fetch_tool_result should not emit tool calls
assert (
tool_call_count == 0
), f"Expected 0 tool calls, got {tool_call_count}. web_fetch_tool_result should not emit tool calls"
def test_container_in_provider_specific_fields_streaming():
"""
Test that container is captured in provider_specific_fields for streaming responses.

View file

@ -181,7 +181,7 @@ def test_bedrock_passthrough_with_application_inference_profile():
This test verifies the fix for GitHub issue #18761 where Bedrock passthrough
was not working with Application Inference Profiles. The model_id (ARN) should
replace the translated model name in the endpoint URL.
replace the translated model name in the endpoint URL and be properly encoded.
"""
config = BedrockPassthroughConfig()
@ -204,19 +204,21 @@ def test_bedrock_passthrough_with_application_inference_profile():
litellm_params={"model_id": model_id, "aws_region_name": "eu-west-1"}
)
# Verify that the URL contains the model_id (ARN) instead of the model name
# Verify that the URL contains the encoded model_id (ARN) instead of the model name
url_str = str(url)
assert model_id in url_str, f"Expected model_id ARN in URL, but got: {url_str}"
# The ARN slash should be encoded as %2F
assert "application-inference-profile%2F" in url_str, f"Expected encoded ARN in URL, but got: {url_str}"
assert model not in url_str, f"Model name should be replaced by model_id, but got: {url_str}"
assert "/invoke" in url_str, "Expected /invoke action in URL"
# Verify the complete URL structure
expected_url = f"https://bedrock-runtime.eu-west-1.amazonaws.com/model/{model_id}/invoke"
# Verify the complete URL structure with encoded ARN
encoded_model_id = "arn:aws:bedrock:eu-west-1:123456789:application-inference-profile%2Fabcdefgh1234"
expected_url = f"https://bedrock-runtime.eu-west-1.amazonaws.com/model/{encoded_model_id}/invoke"
assert url_str == expected_url, f"Expected {expected_url}, but got: {url_str}"
def test_bedrock_passthrough_with_inference_profile_converse_endpoint():
"""Test Application Inference Profile with converse endpoint"""
"""Test Application Inference Profile with converse endpoint and proper ARN encoding"""
config = BedrockPassthroughConfig()
model = "anthropic.claude-sonnet-4-20250514-v1:0"
@ -239,7 +241,8 @@ def test_bedrock_passthrough_with_inference_profile_converse_endpoint():
)
url_str = str(url)
assert model_id in url_str
# The ARN should be encoded with %2F
assert "application-inference-profile%2F" in url_str
assert "/converse" in url_str
assert model not in url_str
@ -304,3 +307,125 @@ def test_bedrock_passthrough_region_extraction_from_inference_profile_arn():
# Verify that the region from ARN is used in the base URL
assert "us-west-2" in api_base, f"Expected region 'us-west-2' from ARN in base URL, but got: {api_base}"
def test_bedrock_passthrough_model_id_arn_encoding():
"""
Test that model_id ARNs are properly URL-encoded when used in endpoints.
This is the critical fix for the issue where ARNs with slashes need to be encoded
so they're treated as a single path component rather than multiple path segments.
For example:
arn:aws:bedrock:us-east-1:590183661440:application-inference-profile/b943q2qbl3m7
should become:
arn:aws:bedrock:us-east-1:590183661440:application-inference-profile%2Fb943q2qbl3m7
"""
config = BedrockPassthroughConfig()
model = "bedrock-claude-4-5-sonnet"
# ARN with a slash that needs encoding
model_id = "arn:aws:bedrock:us-east-1:590183661440:application-inference-profile/b943q2qbl3m7"
endpoint = f"/model/{model}/converse"
with patch.object(config, '_get_aws_region_name', return_value="us-east-1"), \
patch.object(config, 'get_runtime_endpoint', return_value=(
"https://bedrock-runtime.us-east-1.amazonaws.com",
"https://bedrock-runtime.us-east-1.amazonaws.com"
)):
url, api_base = config.get_complete_url(
api_base=None,
api_key=None,
model=model,
endpoint=endpoint,
request_query_params=None,
litellm_params={"model_id": model_id}
)
url_str = str(url)
# The slash in the ARN after application-inference-profile should be encoded as %2F
assert "application-inference-profile%2F" in url_str, \
f"Expected encoded ARN with %2F in URL, but got: {url_str}"
# The unencoded version should NOT be in the URL
assert "application-inference-profile/" not in url_str, \
f"ARN slash should be encoded, but found unencoded version in: {url_str}"
# Verify the complete expected URL structure
expected_encoded_model_id = "arn:aws:bedrock:us-east-1:590183661440:application-inference-profile%2Fb943q2qbl3m7"
expected_url = f"https://bedrock-runtime.us-east-1.amazonaws.com/model/{expected_encoded_model_id}/converse"
assert url_str == expected_url, f"Expected {expected_url}, but got: {url_str}"
def test_bedrock_passthrough_model_id_arn_encoding_invoke_endpoint():
"""
Test ARN encoding with /invoke endpoint (not just /converse).
"""
config = BedrockPassthroughConfig()
model = "anthropic.claude-sonnet-4-5-20250929-v1:0"
model_id = "arn:aws:bedrock:us-east-1:123456789:application-inference-profile/xyz789"
endpoint = f"/model/{model}/invoke"
with patch.object(config, '_get_aws_region_name', return_value="us-east-1"), \
patch.object(config, 'get_runtime_endpoint', return_value=(
"https://bedrock-runtime.us-east-1.amazonaws.com",
"https://bedrock-runtime.us-east-1.amazonaws.com"
)):
url, api_base = config.get_complete_url(
api_base=None,
api_key=None,
model=model,
endpoint=endpoint,
request_query_params=None,
litellm_params={"model_id": model_id}
)
url_str = str(url)
# Verify encoding
assert "application-inference-profile%2F" in url_str
assert "/invoke" in url_str
expected_encoded_model_id = "arn:aws:bedrock:us-east-1:123456789:application-inference-profile%2Fxyz789"
expected_url = f"https://bedrock-runtime.us-east-1.amazonaws.com/model/{expected_encoded_model_id}/invoke"
assert url_str == expected_url
def test_bedrock_passthrough_model_id_without_arn():
"""
Test that non-ARN model_ids (regular model IDs) are not affected by encoding logic.
"""
config = BedrockPassthroughConfig()
model = "my-model"
# Regular model ID (not an ARN)
model_id = "us.anthropic.claude-3-5-sonnet-20240620-v1:0"
endpoint = f"/model/{model}/converse"
with patch.object(config, '_get_aws_region_name', return_value="us-east-1"), \
patch.object(config, 'get_runtime_endpoint', return_value=(
"https://bedrock-runtime.us-east-1.amazonaws.com",
"https://bedrock-runtime.us-east-1.amazonaws.com"
)):
url, api_base = config.get_complete_url(
api_base=None,
api_key=None,
model=model,
endpoint=endpoint,
request_query_params=None,
litellm_params={"model_id": model_id}
)
url_str = str(url)
# Regular model ID should be used as-is (no encoding needed)
assert model_id in url_str
assert "%2F" not in url_str, "Non-ARN model IDs should not be encoded"
expected_url = f"https://bedrock-runtime.us-east-1.amazonaws.com/model/{model_id}/converse"
assert url_str == expected_url

View file

@ -582,7 +582,8 @@ def test_eks_irsa_ambient_credentials_used():
)
# Should create STS client without explicit credentials (using ambient credentials)
mock_boto3_client.assert_called_once_with("sts")
# Note: verify parameter is passed for SSL verification
mock_boto3_client.assert_called_once_with("sts", verify=True)
# Should call assume_role
mock_sts_client.assume_role.assert_called_once_with(
@ -637,11 +638,13 @@ def test_explicit_credentials_used_when_provided():
)
# Should create STS client with explicit credentials
# Note: verify parameter is passed for SSL verification
mock_boto3_client.assert_called_once_with(
"sts",
aws_access_key_id="explicit-access-key",
aws_secret_access_key="explicit-secret-key",
aws_session_token="assumed-session-token",
verify=True,
)
# Should call assume_role
@ -701,6 +704,7 @@ def test_partial_credentials_still_use_ambient():
aws_access_key_id="AKIAEXAMPLE",
aws_secret_access_key=None,
aws_session_token=None,
verify=True,
)
# Should still call assume_role
@ -748,7 +752,7 @@ def test_cross_account_role_assumption():
)
# Should use ambient credentials
mock_boto3_client.assert_called_once_with("sts")
mock_boto3_client.assert_called_once_with("sts", verify=True)
# Should call assume_role with cross-account role
mock_sts_client.assume_role.assert_called_once_with(

View file

@ -0,0 +1,349 @@
"""
Test SSL verification for AWS Bedrock boto3 clients.
This test ensures that custom CA certificates are properly passed to all boto3 clients
(STS and Bedrock services) to support internal certificate authorities.
Issue: https://github.com/BerriAI/litellm/issues/XXXX
User reported that SSL_CERT_FILE environment variable and ssl_verify config were not
being applied to boto3 clients, causing "certificate verify failed" errors.
"""
import os
import sys
import tempfile
from unittest.mock import MagicMock, Mock, patch
import pytest
sys.path.insert(0, os.path.abspath("../.."))
import litellm
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
from litellm.llms.bedrock.common_utils import init_bedrock_client
class TestBedrockSSLVerify:
"""Test suite for SSL verification in Bedrock boto3 clients."""
def test_base_aws_llm_get_ssl_verify_default(self):
"""Test that _get_ssl_verify returns default value when no custom config is set."""
base_aws = BaseAWSLLM()
# Clear any environment variables
os.environ.pop("SSL_VERIFY", None)
os.environ.pop("SSL_CERT_FILE", None)
# Reset litellm.ssl_verify to default
litellm.ssl_verify = True
ssl_verify = base_aws._get_ssl_verify()
assert ssl_verify is True
def test_base_aws_llm_get_ssl_verify_false(self):
"""Test that _get_ssl_verify returns False when SSL verification is disabled."""
base_aws = BaseAWSLLM()
# Set SSL_VERIFY to False via environment
os.environ["SSL_VERIFY"] = "False"
ssl_verify = base_aws._get_ssl_verify()
assert ssl_verify is False
# Clean up
os.environ.pop("SSL_VERIFY", None)
def test_base_aws_llm_get_ssl_verify_custom_ca_bundle(self):
"""Test that _get_ssl_verify returns custom CA bundle path when SSL_CERT_FILE is set."""
base_aws = BaseAWSLLM()
# Create a temporary CA bundle file
with tempfile.NamedTemporaryFile(mode="w", suffix=".pem", delete=False) as f:
f.write("-----BEGIN CERTIFICATE-----\n")
f.write("FAKE CERTIFICATE FOR TESTING\n")
f.write("-----END CERTIFICATE-----\n")
ca_bundle_path = f.name
try:
# Set SSL_CERT_FILE environment variable
os.environ["SSL_CERT_FILE"] = ca_bundle_path
os.environ.pop("SSL_VERIFY", None)
litellm.ssl_verify = True
ssl_verify = base_aws._get_ssl_verify()
assert ssl_verify == ca_bundle_path
finally:
# Clean up
os.environ.pop("SSL_CERT_FILE", None)
os.unlink(ca_bundle_path)
def test_base_aws_llm_get_ssl_verify_litellm_config(self):
"""Test that _get_ssl_verify uses litellm.ssl_verify when set."""
base_aws = BaseAWSLLM()
# Clear environment variables
os.environ.pop("SSL_VERIFY", None)
os.environ.pop("SSL_CERT_FILE", None)
# Create a temporary CA bundle file
with tempfile.NamedTemporaryFile(mode="w", suffix=".pem", delete=False) as f:
f.write("-----BEGIN CERTIFICATE-----\n")
f.write("FAKE CERTIFICATE FOR TESTING\n")
f.write("-----END CERTIFICATE-----\n")
ca_bundle_path = f.name
try:
# Set litellm.ssl_verify to custom CA bundle
litellm.ssl_verify = ca_bundle_path
ssl_verify = base_aws._get_ssl_verify()
# When ssl_verify is a path, it should be returned directly
assert ssl_verify == ca_bundle_path
finally:
# Clean up
litellm.ssl_verify = True
os.unlink(ca_bundle_path)
@patch("boto3.client")
def test_init_bedrock_client_passes_ssl_verify_to_sts(self, mock_boto3_client):
"""Test that init_bedrock_client passes ssl_verify to STS client."""
# Create a temporary CA bundle file
with tempfile.NamedTemporaryFile(mode="w", suffix=".pem", delete=False) as f:
f.write("-----BEGIN CERTIFICATE-----\n")
f.write("FAKE CERTIFICATE FOR TESTING\n")
f.write("-----END CERTIFICATE-----\n")
ca_bundle_path = f.name
try:
# Set SSL_CERT_FILE environment variable
os.environ["SSL_CERT_FILE"] = ca_bundle_path
litellm.ssl_verify = True
# Mock the STS client and Bedrock client
mock_sts_client = MagicMock()
mock_sts_response = {
"Credentials": {
"AccessKeyId": "test_access_key",
"SecretAccessKey": "test_secret_key",
"SessionToken": "test_session_token",
}
}
mock_sts_client.assume_role.return_value = mock_sts_response
mock_bedrock_client = MagicMock()
# Configure mock to return different clients based on service name
def side_effect(service_name=None, **kwargs):
if service_name == "sts":
return mock_sts_client
elif service_name == "bedrock-runtime":
return mock_bedrock_client
return MagicMock()
mock_boto3_client.side_effect = side_effect
# Call init_bedrock_client with role assumption
client = init_bedrock_client(
aws_region_name="us-west-2",
aws_access_key_id="test_key",
aws_secret_access_key="test_secret",
aws_role_name="arn:aws:iam::123456789012:role/test-role",
aws_session_name="test-session",
)
# Verify that boto3.client was called with verify parameter for STS
sts_calls = [
call for call in mock_boto3_client.call_args_list
if (len(call[0]) > 0 and call[0][0] == "sts") or
("service_name" not in call[1]) # STS calls don't use service_name kwarg
]
assert len(sts_calls) > 0, "STS client should have been created"
# Check that verify parameter was passed to STS client
sts_call = sts_calls[0]
assert "verify" in sts_call[1], "verify parameter should be passed to STS client"
assert sts_call[1]["verify"] == ca_bundle_path, f"verify should be set to CA bundle path, got {sts_call[1]['verify']}"
# Verify that boto3.client was called with verify parameter for Bedrock
bedrock_calls = [
call for call in mock_boto3_client.call_args_list
if "service_name" in call[1] and call[1]["service_name"] == "bedrock-runtime"
]
assert len(bedrock_calls) > 0, "Bedrock client should have been created"
bedrock_call = bedrock_calls[0]
assert "verify" in bedrock_call[1], "verify parameter should be passed to Bedrock client"
assert bedrock_call[1]["verify"] == ca_bundle_path, f"verify should be set to CA bundle path, got {bedrock_call[1]['verify']}"
finally:
# Clean up
os.environ.pop("SSL_CERT_FILE", None)
os.unlink(ca_bundle_path)
@patch("boto3.client")
def test_base_aws_llm_auth_with_role_passes_ssl_verify(self, mock_boto3_client):
"""Test that _auth_with_aws_role passes ssl_verify to STS client."""
base_aws = BaseAWSLLM()
# Create a temporary CA bundle file
with tempfile.NamedTemporaryFile(mode="w", suffix=".pem", delete=False) as f:
f.write("-----BEGIN CERTIFICATE-----\n")
f.write("FAKE CERTIFICATE FOR TESTING\n")
f.write("-----END CERTIFICATE-----\n")
ca_bundle_path = f.name
try:
# Set SSL_CERT_FILE environment variable
os.environ["SSL_CERT_FILE"] = ca_bundle_path
litellm.ssl_verify = True
# Mock the STS client
mock_sts_client = MagicMock()
mock_sts_response = {
"Credentials": {
"AccessKeyId": "test_access_key",
"SecretAccessKey": "test_secret_key",
"SessionToken": "test_session_token",
"Expiration": "2025-01-10T00:00:00Z",
}
}
# Convert Expiration to datetime
from datetime import datetime, timezone
mock_sts_response["Credentials"]["Expiration"] = datetime.now(timezone.utc)
mock_sts_client.assume_role.return_value = mock_sts_response
mock_boto3_client.return_value = mock_sts_client
# Call _auth_with_aws_role
credentials, ttl = base_aws._auth_with_aws_role(
aws_access_key_id="test_key",
aws_secret_access_key="test_secret",
aws_session_token=None,
aws_role_name="arn:aws:iam::123456789012:role/test-role",
aws_session_name="test-session",
)
# Verify that boto3.client was called with verify parameter
assert mock_boto3_client.called, "boto3.client should have been called"
call_kwargs = mock_boto3_client.call_args[1]
assert "verify" in call_kwargs, "verify parameter should be passed to STS client"
assert call_kwargs["verify"] == ca_bundle_path, f"verify should be set to CA bundle path, got {call_kwargs['verify']}"
finally:
# Clean up
os.environ.pop("SSL_CERT_FILE", None)
os.unlink(ca_bundle_path)
@patch("litellm.llms.bedrock.base_aws_llm.get_secret")
@patch("boto3.client")
def test_base_aws_llm_auth_with_web_identity_passes_ssl_verify(self, mock_boto3_client, mock_get_secret):
"""Test that _auth_with_web_identity_token passes ssl_verify to STS client."""
base_aws = BaseAWSLLM()
# Create a temporary CA bundle file
with tempfile.NamedTemporaryFile(mode="w", suffix=".pem", delete=False) as f:
f.write("-----BEGIN CERTIFICATE-----\n")
f.write("FAKE CERTIFICATE FOR TESTING\n")
f.write("-----END CERTIFICATE-----\n")
ca_bundle_path = f.name
try:
# Set SSL_CERT_FILE environment variable
os.environ["SSL_CERT_FILE"] = ca_bundle_path
litellm.ssl_verify = True
# Mock get_secret to return the token
mock_get_secret.return_value = "mocked_oidc_token"
# Mock the STS client
mock_sts_client = MagicMock()
mock_sts_response = {
"Credentials": {
"AccessKeyId": "test_access_key",
"SecretAccessKey": "test_secret_key",
"SessionToken": "test_session_token",
},
"PackedPolicySize": 100,
}
mock_sts_client.assume_role_with_web_identity.return_value = mock_sts_response
# Mock boto3.Session
mock_session = MagicMock()
mock_credentials = MagicMock()
mock_session.get_credentials.return_value = mock_credentials
mock_boto3_client.return_value = mock_sts_client
with patch("boto3.Session", return_value=mock_session):
# Call _auth_with_web_identity_token
credentials, ttl = base_aws._auth_with_web_identity_token(
aws_web_identity_token="test_token",
aws_role_name="arn:aws:iam::123456789012:role/test-role",
aws_session_name="test-session",
aws_region_name="us-west-2",
aws_sts_endpoint=None,
)
# Verify that boto3.client was called with verify parameter
assert mock_boto3_client.called, "boto3.client should have been called"
call_kwargs = mock_boto3_client.call_args[1]
assert "verify" in call_kwargs, "verify parameter should be passed to STS client"
assert call_kwargs["verify"] == ca_bundle_path, f"verify should be set to CA bundle path, got {call_kwargs['verify']}"
finally:
# Clean up
os.environ.pop("SSL_CERT_FILE", None)
os.unlink(ca_bundle_path)
def test_ssl_verify_priority_env_over_litellm_config(self):
"""Test that SSL_VERIFY environment variable takes priority over litellm.ssl_verify."""
base_aws = BaseAWSLLM()
# Set litellm.ssl_verify to True
litellm.ssl_verify = True
# Set SSL_VERIFY environment variable to False
os.environ["SSL_VERIFY"] = "False"
try:
ssl_verify = base_aws._get_ssl_verify()
assert ssl_verify is False, "Environment variable should take priority"
finally:
# Clean up
os.environ.pop("SSL_VERIFY", None)
litellm.ssl_verify = True
def test_ssl_cert_file_priority_over_default(self):
"""Test that SSL_CERT_FILE takes priority when ssl_verify is True."""
base_aws = BaseAWSLLM()
# Create a temporary CA bundle file
with tempfile.NamedTemporaryFile(mode="w", suffix=".pem", delete=False) as f:
f.write("-----BEGIN CERTIFICATE-----\n")
f.write("FAKE CERTIFICATE FOR TESTING\n")
f.write("-----END CERTIFICATE-----\n")
ca_bundle_path = f.name
try:
# Set SSL_CERT_FILE environment variable
os.environ["SSL_CERT_FILE"] = ca_bundle_path
os.environ.pop("SSL_VERIFY", None)
litellm.ssl_verify = True
ssl_verify = base_aws._get_ssl_verify()
assert ssl_verify == ca_bundle_path, "SSL_CERT_FILE should be used when ssl_verify is True"
finally:
# Clean up
os.environ.pop("SSL_CERT_FILE", None)
os.unlink(ca_bundle_path)
if __name__ == "__main__":
# Run tests
pytest.main([__file__, "-v", "-s"])

View file

@ -14,6 +14,7 @@ from litellm.llms.vertex_ai.common_utils import VertexAIError
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
VertexGeminiConfig,
)
from litellm.llms.gemini.chat.transformation import GoogleAIStudioGeminiConfig
from litellm.types.llms.vertex_ai import UsageMetadata
from litellm.types.utils import ChoiceLogprobs, Usage
from litellm.utils import CustomStreamWrapper
@ -2315,6 +2316,17 @@ def test_partial_json_chunk_on_first_chunk():
assert iterator.chunk_type == "accumulated_json", "Should switch to accumulated_json mode"
def test_google_ai_studio_presence_penalty_supported():
"""
Test that presence_penalty is supported for Google AI Studio Gemini.
Regression test for https://github.com/BerriAI/litellm/issues/14753
"""
config = GoogleAIStudioGeminiConfig()
supported_params = config.get_supported_openai_params(model="gemini-2.0-flash")
assert "presence_penalty" in supported_params
# ==================== Tool Type Separation Tests ====================
# These tests verify that each Tool object contains exactly one type per Vertex AI API spec
# Ref: https://cloud.google.com/vertex-ai/generative-ai/docs/reference/rest/v1beta1/Tool
@ -2505,3 +2517,72 @@ def test_vertex_ai_multiple_function_declarations_grouped():
func_names = [f["name"] for f in tools[0]["function_declarations"]]
assert "func1" in func_names
assert "func2" in func_names
def test_gemini_3_flash_preview_token_usage_fallback():
"""Test fallback logic when candidatesTokensDetails is missing (e.g. Gemini 3 Flash Preview)."""
v = VertexGeminiConfig()
usage_metadata_dict = {
"promptTokenCount": 2145,
"candidatesTokenCount": 509,
"totalTokenCount": 2654,
# candidatesTokensDetails intentionally omitted
}
completion_response = {"usageMetadata": usage_metadata_dict}
result = v._calculate_usage(completion_response=completion_response)
assert result.completion_tokens == 509
assert result.prompt_tokens == 2145
assert result.total_tokens == 2654
# Text tokens should be derived from candidatesTokenCount
assert result.completion_tokens_details is not None
assert result.completion_tokens_details.text_tokens == 509
assert result.completion_tokens_details.image_tokens is None
assert result.completion_tokens_details.audio_tokens is None
def test_gemini_no_reasoning_fallback():
"""Test fallback when reasoning_effort is absent and details are missing."""
v = VertexGeminiConfig()
usage_metadata_dict = {
"promptTokenCount": 100,
"candidatesTokenCount": 264,
"totalTokenCount": 364,
}
completion_response = {"usageMetadata": usage_metadata_dict}
result = v._calculate_usage(completion_response=completion_response)
assert result.completion_tokens == 264
assert result.completion_tokens_details is not None
assert result.completion_tokens_details.text_tokens == 264
assert (
result.completion_tokens_details.reasoning_tokens is None
or result.completion_tokens_details.reasoning_tokens == 0
)
def test_gemini_token_usage_standard_response():
"""Verify that standard responses with details are computed correctly and not overwritten."""
v = VertexGeminiConfig()
usage_metadata_dict = {
"promptTokenCount": 100,
"candidatesTokenCount": 50,
"totalTokenCount": 150,
"candidatesTokensDetails": [
{"modality": "TEXT", "tokenCount": 40},
{"modality": "IMAGE", "tokenCount": 10},
],
}
completion_response = {"usageMetadata": usage_metadata_dict}
result = v._calculate_usage(completion_response=completion_response)
assert result.completion_tokens == 50
assert result.completion_tokens_details.text_tokens == 40
assert result.completion_tokens_details.image_tokens == 10