Skip to content
1 change: 1 addition & 0 deletions docs/my-website/docs/proxy/logging.md
Original file line number Diff line number Diff line change
Expand Up @@ -1338,6 +1338,7 @@ litellm_settings:
s3_aws_secret_access_key: os.environ/AWS_SECRET_ACCESS_KEY # AWS Secret Access Key for S3
s3_path: my-test-path # [OPTIONAL] set path in bucket you want to write logs to
s3_endpoint_url: https://s3.amazonaws.com # [OPTIONAL] S3 endpoint URL, if you want to use Backblaze/cloudflare s3 buckets
s3_use_virtual_hosted_style: false # [OPTIONAL] use virtual-hosted-style URLs (bucket.endpoint/key) instead of path-style (endpoint/bucket/key). Useful for S3-compatible services like MinIO
s3_strip_base64_files: false # [OPTIONAL] remove base64 files before storing in s3
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,15 +163,23 @@ def convert_chat_completion_messages_to_responses_api(
instructions = f"{instructions} {content}"
else:
instructions = content
elif isinstance(content, list):
# Extract text from content blocks (e.g. [{"type": "text", "text": "..."}])
text_parts = []
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
text_parts.append(block.get("text", ""))
elif isinstance(block, str):
text_parts.append(block)
extracted = " ".join(text_parts)
if instructions:
instructions = f"{instructions} {extracted}"
else:
instructions = extracted
else:
input_items.append(
{
"type": "message",
"role": role,
"content": self._convert_content_to_responses_format(
content, role # type: ignore
),
}
verbose_logger.warning(
"Unexpected system message content type: %s. Skipping.",
type(content),
)
elif role == "tool":
# Convert tool message to function call output format
Expand Down
75 changes: 52 additions & 23 deletions litellm/integrations/s3_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ def __init__(
s3_use_team_prefix: bool = False,
s3_strip_base64_files: bool = False,
s3_use_key_prefix: bool = False,
s3_use_virtual_hosted_style: bool = False,
**kwargs,
):
try:
Expand Down Expand Up @@ -78,7 +79,8 @@ def __init__(
s3_path=s3_path,
s3_use_team_prefix=s3_use_team_prefix,
s3_strip_base64_files=s3_strip_base64_files,
s3_use_key_prefix=s3_use_key_prefix
s3_use_key_prefix=s3_use_key_prefix,
s3_use_virtual_hosted_style=s3_use_virtual_hosted_style
)
verbose_logger.debug(f"s3 logger using endpoint url {s3_endpoint_url}")

Expand Down Expand Up @@ -135,6 +137,7 @@ def _init_s3_params(
s3_use_team_prefix: bool = False,
s3_strip_base64_files: bool = False,
s3_use_key_prefix: bool = False,
s3_use_virtual_hosted_style: bool = False,
):
"""
Initialize the s3 params for this logging callback
Expand Down Expand Up @@ -217,6 +220,11 @@ def _init_s3_params(
or s3_strip_base64_files
)

self.s3_use_virtual_hosted_style = (
bool(litellm.s3_callback_params.get("s3_use_virtual_hosted_style", False))
or s3_use_virtual_hosted_style
)

return

async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
Expand Down Expand Up @@ -302,13 +310,20 @@ async def async_upload_data_to_s3(
url = f"https://{self.s3_bucket_name}.s3.{self.s3_region_name}.amazonaws.com/{batch_logging_element.s3_object_key}"

if self.s3_endpoint_url and self.s3_bucket_name:
url = (
self.s3_endpoint_url
+ "/"
+ self.s3_bucket_name
+ "/"
+ batch_logging_element.s3_object_key
)
if self.s3_use_virtual_hosted_style:
# Virtual-hosted-style: bucket.endpoint/key
endpoint_host = self.s3_endpoint_url.replace("https://", "").replace("http://", "")
protocol = "https://" if self.s3_endpoint_url.startswith("https://") else "http://"
url = f"{protocol}{self.s3_bucket_name}.{endpoint_host}/{batch_logging_element.s3_object_key}"
else:
# Path-style: endpoint/bucket/key
url = (
self.s3_endpoint_url
+ "/"
+ self.s3_bucket_name
+ "/"
+ batch_logging_element.s3_object_key
)

# Convert JSON to string
json_string = safe_dumps(batch_logging_element.payload)
Expand Down Expand Up @@ -456,13 +471,20 @@ def upload_data_to_s3(self, batch_logging_element: s3BatchLoggingElement):
url = f"https://{self.s3_bucket_name}.s3.{self.s3_region_name}.amazonaws.com/{batch_logging_element.s3_object_key}"

if self.s3_endpoint_url and self.s3_bucket_name:
url = (
self.s3_endpoint_url
+ "/"
+ self.s3_bucket_name
+ "/"
+ batch_logging_element.s3_object_key
)
if self.s3_use_virtual_hosted_style:
# Virtual-hosted-style: bucket.endpoint/key
endpoint_host = self.s3_endpoint_url.replace("https://", "").replace("http://", "")
protocol = "https://" if self.s3_endpoint_url.startswith("https://") else "http://"
url = f"{protocol}{self.s3_bucket_name}.{endpoint_host}/{batch_logging_element.s3_object_key}"
else:
# Path-style: endpoint/bucket/key
url = (
self.s3_endpoint_url
+ "/"
+ self.s3_bucket_name
+ "/"
+ batch_logging_element.s3_object_key
)

# Convert JSON to string
json_string = safe_dumps(batch_logging_element.payload)
Expand Down Expand Up @@ -550,13 +572,20 @@ async def _download_object_from_s3(self, s3_object_key: str) -> Optional[dict]:
url = f"https://{self.s3_bucket_name}.s3.{self.s3_region_name}.amazonaws.com/{s3_object_key}"

if self.s3_endpoint_url and self.s3_bucket_name:
url = (
self.s3_endpoint_url
+ "/"
+ self.s3_bucket_name
+ "/"
+ s3_object_key
)
if self.s3_use_virtual_hosted_style:
# Virtual-hosted-style: bucket.endpoint/key
endpoint_host = self.s3_endpoint_url.replace("https://", "").replace("http://", "")
protocol = "https://" if self.s3_endpoint_url.startswith("https://") else "http://"
url = f"{protocol}{self.s3_bucket_name}.{endpoint_host}/{s3_object_key}"
else:
# Path-style: endpoint/bucket/key
url = (
self.s3_endpoint_url
+ "/"
+ self.s3_bucket_name
+ "/"
+ s3_object_key
)

# Prepare the request for GET operation
# For GET requests, we need x-amz-content-sha256 with hash of empty string
Expand Down Expand Up @@ -618,4 +647,4 @@ async def get_proxy_server_request_from_cold_storage_with_object_key(
verbose_logger.exception(
f"Error retrieving object {object_key} from cold storage: {str(e)}"
)
return None
return None
8 changes: 6 additions & 2 deletions litellm/llms/anthropic/chat/transformation.py
Original file line number Diff line number Diff line change
Expand Up @@ -1282,9 +1282,13 @@ def transform_request(
output_config = optional_params.get("output_config")
if output_config and isinstance(output_config, dict):
effort = output_config.get("effort")
if effort and effort not in ["high", "medium", "low"]:
if effort and effort not in ["high", "medium", "low", "max"]:
raise ValueError(
f"Invalid effort value: {effort}. Must be one of: 'high', 'medium', 'low'"
f"Invalid effort value: {effort}. Must be one of: 'high', 'medium', 'low', 'max'"
)
if effort == "max" and not self._is_claude_opus_4_6(model):
raise ValueError(
f"effort='max' is only supported by Claude Opus 4.6. Got model: {model}"
)
data["output_config"] = output_config

Expand Down
12 changes: 9 additions & 3 deletions litellm/llms/custom_httpx/aiohttp_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,16 +119,21 @@ async def aclose(self) -> None:


class AiohttpTransport(httpx.AsyncBaseTransport):
def __init__(self, client: Union[ClientSession, Callable[[], ClientSession]]) -> None:
def __init__(
self,
client: Union[ClientSession, Callable[[], ClientSession]],
owns_session: bool = True,
) -> None:
self.client = client
self._owns_session = owns_session

#########################################################
# Class variables for proxy settings
#########################################################
self.proxy_cache: Dict[str, Optional[str]] = {}

async def aclose(self) -> None:
if isinstance(self.client, ClientSession):
if self._owns_session and isinstance(self.client, ClientSession):
await self.client.close()


Expand All @@ -144,10 +149,11 @@ def __init__(
self,
client: Union[ClientSession, Callable[[], ClientSession]],
ssl_verify: Optional[Union[bool, ssl.SSLContext]] = None,
owns_session: bool = True,
):
self.client = client
self._ssl_verify = ssl_verify # Store for per-request SSL override
super().__init__(client=client)
super().__init__(client=client, owns_session=owns_session)
# Store the client factory for recreating sessions when needed
if callable(client):
self._client_factory = client
Expand Down
1 change: 1 addition & 0 deletions litellm/llms/custom_httpx/http_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -866,6 +866,7 @@ def _create_aiohttp_transport(
return LiteLLMAiohttpTransport(
client=shared_session,
ssl_verify=ssl_for_transport,
owns_session=False,
)

# Create new session only if none provided or existing one is invalid
Expand Down
11 changes: 11 additions & 0 deletions litellm/proxy/auth/auth_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,17 @@ async def common_checks(
f"Team={team_object.team_id} is blocked. Update via `/team/unblock` if your admin."
)

# 1.1. If user is deactivated via SCIM
if user_object is not None and user_object.metadata is not None:
scim_active = user_object.metadata.get("scim_active")
if scim_active is False:
raise ProxyException(
message="User account is deactivated.",
type=ProxyErrorTypes.auth_error,
param="user_id",
code=status.HTTP_401_UNAUTHORIZED,
)

# 2. If team can call model
if _model and team_object:
if not await can_team_access_model(
Expand Down
7 changes: 0 additions & 7 deletions litellm/proxy/db/db_spend_update_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -1725,13 +1725,6 @@ async def add_spend_log_transaction_to_daily_agent_transaction(
"prisma_client is None. Skipping writing spend logs to db."
)
return
base_daily_transaction = (
await self._common_add_spend_log_transaction_to_daily_transaction(
payload, prisma_client, "agent"
)
)
if base_daily_transaction is None:
return
if payload["agent_id"] is None:
verbose_proxy_logger.debug(
"agent_id is None for request. Skipping incrementing agent spend."
Expand Down
2 changes: 2 additions & 0 deletions litellm/proxy/policy_engine/policy_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any, Dict, List, Optional

from prisma import Json as PrismaJson

from litellm._logging import verbose_proxy_logger
from litellm.types.proxy.policy_engine import (
GuardrailPipeline,
Expand Down
2 changes: 1 addition & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -1278,3 +1278,86 @@ def test_transform_response_preserves_annotations():
assert result.usage.total_tokens == 30

print("✓ Annotations from Responses API are correctly preserved in Chat Completions format")


def test_convert_chat_completion_messages_to_responses_api_system_string():
"""Test that string system content is extracted into instructions."""
from litellm.completion_extras.litellm_responses_transformation.transformation import (
LiteLLMResponsesTransformationHandler,
)

handler = LiteLLMResponsesTransformationHandler()

messages = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Hello"},
]

input_items, instructions = handler.convert_chat_completion_messages_to_responses_api(messages)

assert instructions == "You are a helpful assistant."
# System message should NOT appear in input items
for item in input_items:
assert item.get("role") != "system"
# User message should be in input items
assert len(input_items) == 1
assert input_items[0]["role"] == "user"


def test_convert_chat_completion_messages_to_responses_api_system_list_content():
"""Test that list-format system content blocks are extracted into instructions.

This happens when requests arrive via the Anthropic /v1/messages adapter,
which converts system prompts into list-format content blocks.
"""
from litellm.completion_extras.litellm_responses_transformation.transformation import (
LiteLLMResponsesTransformationHandler,
)

handler = LiteLLMResponsesTransformationHandler()

messages = [
{
"role": "system",
"content": [
{"type": "text", "text": "You are a helpful assistant."},
{"type": "text", "text": "Be concise."},
],
},
{"role": "user", "content": "Hello"},
]

input_items, instructions = handler.convert_chat_completion_messages_to_responses_api(messages)

assert instructions == "You are a helpful assistant. Be concise."
# System message should NOT appear in input items
for item in input_items:
assert item.get("role") != "system"
assert len(input_items) == 1
assert input_items[0]["role"] == "user"


def test_convert_chat_completion_messages_to_responses_api_multiple_system_messages():
"""Test that multiple system messages (string and list) are concatenated."""
from litellm.completion_extras.litellm_responses_transformation.transformation import (
LiteLLMResponsesTransformationHandler,
)

handler = LiteLLMResponsesTransformationHandler()

messages = [
{"role": "system", "content": "You are a helpful assistant."},
{
"role": "system",
"content": [
{"type": "text", "text": "Be concise."},
],
},
{"role": "user", "content": "Hello"},
]

input_items, instructions = handler.convert_chat_completion_messages_to_responses_api(messages)

assert instructions == "You are a helpful assistant. Be concise."
for item in input_items:
assert item.get("role") != "system"
Loading
Loading