Merge pull request #21044 from BerriAI/litellm_oss_staging_02_07_20262

Litellm oss staging 02 07 20262
This commit is contained in:
Sameer Kankute 2026-02-13 17:53:32 +05:30 committed by GitHub
commit e85da33240
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 768 additions and 69 deletions

View file

@ -40,38 +40,33 @@ outputs:
runs:
using: composite
steps:
- name: Helm | Setup
uses: azure/setup-helm@v4
with:
version: v3.20.0
- name: Helm | Login
shell: bash
run: echo ${{ inputs.registry_password }} | helm registry login -u ${{ inputs.registry_username }} --password-stdin ${{ inputs.registry }}
env:
HELM_EXPERIMENTAL_OCI: '1'
- name: Helm | Dependency
if: inputs.update_dependencies == 'true'
shell: bash
run: helm dependency update ${{ inputs.path == null && format('{0}/{1}', 'charts', inputs.name) || inputs.path }}
env:
HELM_EXPERIMENTAL_OCI: '1'
- name: Helm | Package
shell: bash
run: helm package ${{ inputs.path == null && format('{0}/{1}', 'charts', inputs.name) || inputs.path }} --version ${{ inputs.tag }} --app-version ${{ inputs.app_version }}
env:
HELM_EXPERIMENTAL_OCI: '1'
- name: Helm | Push
shell: bash
run: helm push ${{ inputs.name }}-${{ inputs.tag }}.tgz oci://${{ inputs.registry }}/${{ inputs.repository }}
env:
HELM_EXPERIMENTAL_OCI: '1'
- name: Helm | Logout
shell: bash
run: helm registry logout ${{ inputs.registry }}
env:
HELM_EXPERIMENTAL_OCI: '1'
- name: Helm | Output
id: output
shell: bash
run: echo "image=${{ inputs.registry }}/${{ inputs.repository }}/${{ inputs.name }}:${{ inputs.tag }}" >> $GITHUB_OUTPUT
run: echo "image=${{ inputs.registry }}/${{ inputs.repository }}/${{ inputs.name }}:${{ inputs.tag }}" >> $GITHUB_OUTPUT

View file

@ -26,6 +26,10 @@ version: 1.1.0
# It is recommended to use it with quotes.
appVersion: v1.80.12
annotations:
org.opencontainers.image.source: "https://github.com/BerriAI/litellm"
org.opencontainers.image.url: "https://docs.litellm.ai/"
dependencies:
- name: "postgresql"
version: ">=13.3.0"

View file

@ -18,7 +18,7 @@ Each provider uses their own search backend:
| Provider | Search Engine | Notes |
|----------|---------------|-------|
| **OpenAI** (`gpt-4o-search-preview`, `gpt-4o-mini-search-preview`, `gpt-5-search-api`) | OpenAI's internal search | Real-time web data |
| **OpenAI** (`gpt-5-search-api`, `gpt-4o-search-preview`, `gpt-4o-mini-search-preview`) | OpenAI's internal search | Real-time web data |
| **xAI** (`grok-3`) | xAI's search + X/Twitter | Real-time social media data |
| **Google AI/Vertex** (`gemini-2.0-flash`) | **Google Search** | Uses actual Google search results |
| **Anthropic** (`claude-3-5-sonnet`) | Anthropic's web search | Real-time web data |
@ -45,6 +45,19 @@ Use `web_search_options` when you need to:
**Anthropic Web Search Models**: Claude models that support web search: `claude-3-5-sonnet-latest`, `claude-3-5-sonnet-20241022`, `claude-3-5-haiku-latest`, `claude-3-5-haiku-20241022`, `claude-3-7-sonnet-20250219`
:::
## OpenAI Web Search: Two Approaches
OpenAI offers two distinct ways to use web search depending on the endpoint and model:
| Approach | Endpoint | Models | How to enable |
|----------|----------|--------|---------------|
| **Search Models** | `/chat/completions` | `gpt-5-search-api`, `gpt-4o-search-preview`, `gpt-4o-mini-search-preview` | Pass `web_search_options` parameter |
| **Web Search Tool** | `/responses` | `gpt-5`, `gpt-4.1`, `gpt-4o`, and other regular models | Pass `web_search_preview` tool |
:::tip Search models search automatically
Search models like `gpt-5-search-api` **automatically search the web** even without the `web_search_options` parameter. Use `web_search_options` to set `search_context_size` (`"low"`, `"medium"`, `"high"`) or specify `user_location` for localized results.
:::
## `/chat/completions` (litellm.completion)
### Quick Start
@ -56,7 +69,7 @@ Use `web_search_options` when you need to:
from litellm import completion
response = completion(
model="openai/gpt-4o-search-preview",
model="openai/gpt-5-search-api",
messages=[
{
"role": "user",
@ -76,31 +89,36 @@ response = completion(
```yaml
model_list:
# OpenAI
# OpenAI search models
- model_name: gpt-5-search-api
litellm_params:
model: openai/gpt-5-search-api
api_key: os.environ/OPENAI_API_KEY
- model_name: gpt-4o-search-preview
litellm_params:
model: openai/gpt-4o-search-preview
api_key: os.environ/OPENAI_API_KEY
# xAI
- model_name: grok-3
litellm_params:
model: xai/grok-3
api_key: os.environ/XAI_API_KEY
# Anthropic
- model_name: claude-3-5-sonnet-latest
litellm_params:
model: anthropic/claude-3-5-sonnet-latest
api_key: os.environ/ANTHROPIC_API_KEY
# VertexAI
- model_name: gemini-2-flash
litellm_params:
model: gemini-2.0-flash
vertex_project: your-project-id
vertex_location: us-central1
# Google AI Studio
- model_name: gemini-2-flash-studio
litellm_params:
@ -108,13 +126,13 @@ model_list:
api_key: os.environ/GOOGLE_API_KEY
```
2. Start the proxy
2. Start the proxy
```bash
litellm --config /path/to/config.yaml
```
3. Test it!
3. Test it!
```python showLineNumbers
from openai import OpenAI
@ -126,13 +144,18 @@ client = OpenAI(
)
response = client.chat.completions.create(
model="grok-3", # or any other web search enabled model
model="gpt-5-search-api", # or any other web search enabled model
messages=[
{
"role": "user",
"content": "What was a positive news story from today?"
}
]
],
extra_body={
"web_search_options": {
"search_context_size": "medium"
}
}
)
```
</TabItem>
@ -149,7 +172,7 @@ from litellm import completion
# Customize search context size
response = completion(
model="openai/gpt-4o-search-preview",
model="openai/gpt-5-search-api",
messages=[
{
"role": "user",
@ -257,6 +280,12 @@ response = client.chat.completions.create(
## `/responses` (litellm.responses)
Use the `web_search_preview` tool with models like `gpt-5`, `gpt-4.1`, `gpt-4o`, etc.
:::info
Search-dedicated models like `gpt-5-search-api` and `gpt-4o-search-preview` do **not** support the `/responses` endpoint. Use them with `/chat/completions` + `web_search_options` instead (see above).
:::
### Quick Start
<Tabs>
@ -266,18 +295,14 @@ response = client.chat.completions.create(
from litellm import responses
response = responses(
model="openai/gpt-4o",
input=[
{
"role": "user",
"content": "What was a positive news story from today?"
}
],
model="openai/gpt-5",
input="What is the capital of France?",
tools=[{
"type": "web_search_preview" # enables web search with default medium context size
}]
)
```
</TabItem>
<TabItem value="proxy" label="PROXY">
@ -285,19 +310,24 @@ response = responses(
```yaml
model_list:
- model_name: gpt-4o
- model_name: gpt-5
litellm_params:
model: openai/gpt-4o
model: openai/gpt-5
api_key: os.environ/OPENAI_API_KEY
- model_name: gpt-4.1
litellm_params:
model: openai/gpt-4.1
api_key: os.environ/OPENAI_API_KEY
```
2. Start the proxy
2. Start the proxy
```bash
litellm --config /path/to/config.yaml
```
3. Test it!
3. Test it!
```python showLineNumbers
from openai import OpenAI
@ -309,11 +339,11 @@ client = OpenAI(
)
response = client.responses.create(
model="gpt-4o",
model="gpt-5",
tools=[{
"type": "web_search_preview"
}],
input="What was a positive news story from today?",
input="What is the capital of France?",
)
print(response.output_text)
@ -331,13 +361,8 @@ from litellm import responses
# Customize search context size
response = responses(
model="openai/gpt-4o",
input=[
{
"role": "user",
"content": "What was a positive news story from today?"
}
],
model="openai/gpt-5",
input="What is the capital of France?",
tools=[{
"type": "web_search_preview",
"search_context_size": "low" # Options: "low", "medium" (default), "high"
@ -358,12 +383,12 @@ client = OpenAI(
# Customize search context size
response = client.responses.create(
model="gpt-4o",
model="gpt-5",
tools=[{
"type": "web_search_preview",
"search_context_size": "low" # Options: "low", "medium" (default), "high"
}],
input="What was a positive news story from today?",
input="What is the capital of France?",
)
print(response.output_text)
@ -417,14 +442,14 @@ model_list:
web_search_options:
search_context_size: "high" # Options: "low", "medium", "high"
# Different context size for different models
- model_name: gpt-4o-search-preview
# OpenAI search model with custom context size
- model_name: gpt-5-search-api
litellm_params:
model: openai/gpt-4o-search-preview
model: openai/gpt-5-search-api
api_key: os.environ/OPENAI_API_KEY
web_search_options:
search_context_size: "low"
# Gemini with medium context (default)
- model_name: gemini-2-flash
litellm_params:
@ -449,6 +474,7 @@ Use `litellm.supports_web_search(model="model_name")` -> returns `True` if model
```python showLineNumbers
# Check OpenAI models
assert litellm.supports_web_search(model="openai/gpt-5-search-api") == True
assert litellm.supports_web_search(model="openai/gpt-4o-search-preview") == True
# Check xAI models
@ -472,13 +498,20 @@ assert litellm.supports_web_search(model="gemini/gemini-2.0-flash") == True
```yaml
model_list:
# OpenAI
- model_name: gpt-5-search-api
litellm_params:
model: openai/gpt-5-search-api
api_key: os.environ/OPENAI_API_KEY
model_info:
supports_web_search: True
- model_name: gpt-4o-search-preview
litellm_params:
model: openai/gpt-4o-search-preview
api_key: os.environ/OPENAI_API_KEY
model_info:
supports_web_search: True
# xAI
- model_name: grok-3
litellm_params:
@ -533,6 +566,12 @@ Expected Response
```json showLineNumbers
{
"data": [
{
"model_group": "gpt-5-search-api",
"providers": ["openai"],
"max_tokens": 128000,
"supports_web_search": true
},
{
"model_group": "gpt-4o-search-preview",
"providers": ["openai"],

View file

@ -230,7 +230,70 @@ os.environ["OPENAI_BASE_URL"] = "https://your_host/v1" # OPTIONAL
These also support the `OPENAI_BASE_URL` environment variable, which can be used to specify a custom API endpoint.
## OpenAI Vision Models
### OpenAI Web Search Models
OpenAI has two ways to use web search, depending on the endpoint:
| Approach | Endpoint | Models | How to enable |
|----------|----------|--------|---------------|
| **Search Models** | `/chat/completions` | `gpt-5-search-api`, `gpt-4o-search-preview`, `gpt-4o-mini-search-preview` | Pass `web_search_options` parameter |
| **Web Search Tool** | `/responses` | `gpt-5`, `gpt-4.1`, `gpt-4o`, and other regular models | Pass `web_search_preview` tool |
<Tabs>
<TabItem value="sdk-completion" label="SDK - /chat/completions">
```python showLineNumbers
from litellm import completion
response = completion(
model="openai/gpt-5-search-api",
messages=[{"role": "user", "content": "What is the capital of France?"}],
web_search_options={
"search_context_size": "medium" # Options: "low", "medium", "high"
}
)
```
</TabItem>
<TabItem value="sdk-responses" label="SDK - /responses">
```python showLineNumbers
from litellm import responses
response = responses(
model="openai/gpt-5",
input="What is the capital of France?",
tools=[{
"type": "web_search_preview",
"search_context_size": "low"
}]
)
```
</TabItem>
<TabItem value="proxy" label="PROXY">
```yaml
model_list:
# Search model for /chat/completions
- model_name: gpt-5-search-api
litellm_params:
model: openai/gpt-5-search-api
api_key: os.environ/OPENAI_API_KEY
# Regular model for /responses with web_search_preview tool
- model_name: gpt-5
litellm_params:
model: openai/gpt-5
api_key: os.environ/OPENAI_API_KEY
```
</TabItem>
</Tabs>
For full details, see the [Web Search guide](../completion/web_search.md).
## OpenAI Vision Models
| Model Name | Function Call |
|-----------------------|-----------------------------------------------------------------|
| gpt-4o | `response = completion(model="gpt-4o", messages=messages)` |

View file

@ -37,6 +37,24 @@ for event in response:
print(event)
```
#### Web Search
```python showLineNumbers title="OpenAI Responses with Web Search"
import litellm
response = litellm.responses(
model="openai/gpt-5",
input="What is the capital of France?",
tools=[{
"type": "web_search_preview",
"search_context_size": "medium" # Options: "low", "medium", "high"
}]
)
print(response)
```
For full details, see the [Web Search guide](../../completion/web_search.md).
#### Image Generation with Streaming
```python showLineNumbers title="OpenAI Streaming Image Generation"
import litellm

View file

@ -1206,7 +1206,28 @@ def get_async_httpx_client(
If not present, creates a new client
Caches the new client and returns it.
Note: When shared_session is provided, the cache is bypassed to ensure
the user's session (with its trace_configs, connector settings, etc.)
is used for the request.
"""
# When shared_session is provided, bypass cache and create a new handler
# that uses the user's session directly. This preserves the user's
# session configuration including trace_configs for aiohttp tracing.
if shared_session is not None:
verbose_logger.debug(
f"shared_session provided (ID: {id(shared_session)}), bypassing client cache"
)
if params is not None:
handler_params = {k: v for k, v in params.items() if k != "disable_aiohttp_transport"}
handler_params["shared_session"] = shared_session
return AsyncHTTPHandler(**handler_params)
else:
return AsyncHTTPHandler(
timeout=httpx.Timeout(timeout=600.0, connect=5.0),
shared_session=shared_session,
)
_params_key_name = ""
if params is not None:
for key, value in params.items():
@ -1233,12 +1254,10 @@ def get_async_httpx_client(
if params is not None:
# Filter out params that are only used for cache key, not for AsyncHTTPHandler.__init__
handler_params = {k: v for k, v in params.items() if k != "disable_aiohttp_transport"}
handler_params["shared_session"] = shared_session
_new_client = AsyncHTTPHandler(**handler_params)
else:
_new_client = AsyncHTTPHandler(
timeout=httpx.Timeout(timeout=600.0, connect=5.0),
shared_session=shared_session,
)
cache.set_cache(

View file

@ -2,7 +2,7 @@ from typing import TYPE_CHECKING, Any, Dict, Optional, Union, cast, get_type_hin
import httpx
from openai.types.responses import ResponseReasoningItem
from pydantic import BaseModel
from pydantic import BaseModel, ValidationError
import litellm
from litellm._logging import verbose_logger
@ -249,7 +249,17 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
parsed_chunk["error"]["code"] = "unknown_error"
except Exception:
verbose_logger.debug("Failed to coalesce error.code in parsed_chunk")
return event_pydantic_model(**parsed_chunk)
try:
return event_pydantic_model(**parsed_chunk)
except ValidationError:
verbose_logger.debug(
"Pydantic validation failed for %s with chunk %s, "
"falling back to model_construct",
event_pydantic_model.__name__,
parsed_chunk,
)
return event_pydantic_model.model_construct(**parsed_chunk)
@staticmethod
def get_event_model_class(event_type: str) -> Any:

View file

@ -24381,11 +24381,8 @@
"max_input_tokens": 272000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "responses",
"mode": "chat",
"output_cost_per_token": 1.4e-05,
"supported_endpoints": [
"/v1/responses"
],
"supported_modalities": [
"text",
"image"

View file

@ -925,10 +925,11 @@ async def _user_api_key_auth_builder( # noqa: PLR0915
if isinstance(
api_key, str
): # if generated token, make sure it starts with sk-.
_masked_key = "{}****{}".format(api_key[:4], api_key[-4:]) if len(api_key) > 8 else "****"
assert api_key.startswith(
"sk-"
), "LiteLLM Virtual Key expected. Received={}, expected to start with 'sk-'.".format(
api_key
_masked_key
) # prevent token hashes from being used
else:
verbose_logger.warning(

View file

@ -386,7 +386,11 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail):
continue
return final_results
except Exception as e:
raise e
# Sanitize exception to avoid leaking the original text (which may
# contain API keys or other secrets) in error responses.
raise Exception(
f"Presidio PII analysis failed: {type(e).__name__}"
) from e
async def anonymize_text(
self,
@ -443,9 +447,15 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail):
)
return redacted_text["text"]
else:
raise Exception(f"Invalid anonymizer response: {redacted_text}")
raise Exception("Invalid anonymizer response: received None")
except Exception as e:
raise e
# Sanitize exception to avoid leaking the original text (which may
# contain API keys or other secrets) in error responses.
if "Invalid anonymizer response" in str(e):
raise
raise Exception(
f"Presidio PII anonymization failed: {type(e).__name__}"
) from e
def filter_analyze_results_by_score(
self, analyze_results: Union[List[PresidioAnalyzeResponseItem], Dict]

View file

@ -628,10 +628,11 @@ async def _common_key_generation_helper( # noqa: PLR0915
# Validate user-provided key format
if data.key is not None and not data.key.startswith("sk-"):
_masked = "{}****{}".format(data.key[:4], data.key[-4:]) if len(data.key) > 8 else "****"
raise HTTPException(
status_code=400,
detail={
"error": f"Invalid key format. LiteLLM Virtual Key must start with 'sk-'. Received: {data.key}"
"error": f"Invalid key format. LiteLLM Virtual Key must start with 'sk-'. Received: {_masked}"
},
)

View file

@ -24381,11 +24381,8 @@
"max_input_tokens": 272000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "responses",
"mode": "chat",
"output_cost_per_token": 1.4e-05,
"supported_endpoints": [
"/v1/responses"
],
"supported_modalities": [
"text",
"image"

View file

@ -480,6 +480,85 @@ async def test_session_reuse_integration():
await client2.close()
@pytest.mark.asyncio
async def test_shared_session_bypasses_cache():
"""
Test that when shared_session is provided, the cache is bypassed.
This is critical for aiohttp tracing support - users need their custom
ClientSession (with trace_configs) to be used, not a cached session.
Related: GitHub issue #20174
"""
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
from litellm.types.utils import LlmProviders
# First, get a cached client without shared_session
cached_client = get_async_httpx_client(
llm_provider=LlmProviders.ANTHROPIC,
shared_session=None
)
# Now create a mock shared session
mock_session = MockClientSession()
# Get a client WITH shared_session - this should NOT return the cached client
client_with_session = get_async_httpx_client(
llm_provider=LlmProviders.ANTHROPIC, # Same provider!
shared_session=mock_session # type: ignore
)
# The clients should be DIFFERENT - cache should be bypassed when shared_session is provided
assert client_with_session is not cached_client, \
"Cache should be bypassed when shared_session is provided"
# Verify the shared_session handler is using our mock session
# The transport should have our mock_session as its client
transport = client_with_session.client._transport
if hasattr(transport, 'client'):
assert transport.client is mock_session, \
"Handler should use the provided shared_session"
# Clean up
await cached_client.close()
await client_with_session.close()
@pytest.mark.asyncio
async def test_shared_session_each_call_gets_new_handler():
"""
Test that each call with shared_session creates a new handler.
This ensures user sessions (with their trace_configs, etc.) are always
used and not affected by caching.
"""
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
from litellm.types.utils import LlmProviders
# Create two different mock sessions
mock_session1 = MockClientSession()
mock_session2 = MockClientSession()
# Get clients with different sessions for the same provider
client1 = get_async_httpx_client(
llm_provider=LlmProviders.ANTHROPIC,
shared_session=mock_session1 # type: ignore
)
client2 = get_async_httpx_client(
llm_provider=LlmProviders.ANTHROPIC, # Same provider
shared_session=mock_session2 # type: ignore # Different session
)
# Should be different clients, each using their own session
assert client1 is not client2, \
"Different shared_sessions should create different handlers"
# Clean up
await client1.close()
await client2.close()
@pytest.mark.asyncio
async def test_session_validation():
"""Test that session validation works correctly"""

View file

@ -417,6 +417,129 @@ class TestOpenAIResponsesAPIConfig:
assert event.error.code == "unknown_error"
assert event.error.message == "Something went wrong"
def test_transform_streaming_response_missing_required_fields_response_created(
self,
):
"""Test that ResponseCreatedEvent with missing required fields (created_at,
output) does not crash but falls back to model_construct.
Reproduces https://github.com/BerriAI/litellm/issues/20570
"""
from litellm.types.llms.openai import ResponseCreatedEvent
# Minimal payload an OpenAI-compatible provider might send,
# omitting `created_at` and `output` inside the response object.
parsed_chunk = {
"type": "response.created",
"response": {
"id": "resp_q7BOLpck7clq",
"model": "gpt-oss-120b",
"status": "in_progress",
},
}
result = self.config.transform_streaming_response(
model=self.model, parsed_chunk=parsed_chunk, logging_obj=self.logging_obj
)
assert isinstance(result, ResponseCreatedEvent)
assert result.type == ResponsesAPIStreamEvents.RESPONSE_CREATED
assert result.response["id"] == "resp_q7BOLpck7clq"
def test_transform_streaming_response_missing_required_fields_output_text_delta(
self,
):
"""Test that OutputTextDeltaEvent with missing output_index and
content_index falls back to model_construct without crashing.
Reproduces https://github.com/BerriAI/litellm/issues/20570
"""
from litellm.types.llms.openai import OutputTextDeltaEvent
# Provider omits output_index and content_index
parsed_chunk = {
"type": "response.output_text.delta",
"item_id": "item_456",
"delta": "Hello",
}
result = self.config.transform_streaming_response(
model=self.model, parsed_chunk=parsed_chunk, logging_obj=self.logging_obj
)
assert isinstance(result, OutputTextDeltaEvent)
assert result.type == ResponsesAPIStreamEvents.OUTPUT_TEXT_DELTA
assert result.delta == "Hello"
assert result.item_id == "item_456"
def test_transform_streaming_response_missing_required_fields_content_part_added(
self,
):
"""Test that ContentPartAddedEvent with missing output_index and
content_index falls back to model_construct without crashing.
Reproduces https://github.com/BerriAI/litellm/issues/20570
"""
from litellm.types.llms.openai import ContentPartAddedEvent
# Provider omits output_index and content_index
parsed_chunk = {
"type": "response.content_part.added",
"item_id": "item_789",
"part": {"type": "output_text", "text": ""},
}
result = self.config.transform_streaming_response(
model=self.model, parsed_chunk=parsed_chunk, logging_obj=self.logging_obj
)
assert isinstance(result, ContentPartAddedEvent)
assert result.type == ResponsesAPIStreamEvents.CONTENT_PART_ADDED
assert result.item_id == "item_789"
def test_transform_streaming_response_missing_required_fields_output_item_added(
self,
):
"""Test that OutputItemAddedEvent with missing output_index falls back
to model_construct without crashing.
Reproduces https://github.com/BerriAI/litellm/issues/20570
"""
from litellm.types.llms.openai import OutputItemAddedEvent
# Provider omits output_index
parsed_chunk = {
"type": "response.output_item.added",
"item": {"type": "message", "id": "msg_001", "role": "assistant"},
}
result = self.config.transform_streaming_response(
model=self.model, parsed_chunk=parsed_chunk, logging_obj=self.logging_obj
)
assert isinstance(result, OutputItemAddedEvent)
assert result.type == ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED
def test_transform_streaming_response_valid_chunk_still_works(self):
"""Ensure that fully valid chunks still go through normal Pydantic
validation (not model_construct) and work correctly."""
parsed_chunk = {
"type": "response.output_text.delta",
"item_id": "item_123",
"output_index": 0,
"content_index": 0,
"delta": "World",
}
result = self.config.transform_streaming_response(
model=self.model, parsed_chunk=parsed_chunk, logging_obj=self.logging_obj
)
assert isinstance(result, OutputTextDeltaEvent)
assert result.delta == "World"
assert result.output_index == 0
assert result.content_index == 0
class TestAzureResponsesAPIConfig:
def setup_method(self):

View file

@ -697,3 +697,67 @@ def test_populate_request_with_path_params_does_not_overwrite_existing_values():
assert result["organization_id"] == "org-existing" # Should keep original, not "org-query-param"
# Verify other data is preserved
assert result["messages"] == [{"role": "user", "content": "Hello"}]
@pytest.mark.asyncio
async def test_request_body_with_html_script_tags():
"""
Test that JSON request bodies containing HTML tags like <script> are
parsed correctly without being blocked or modified.
Regression test for GitHub issue #20441:
https://github.com/BerriAI/litellm/issues/20441
LLM message content frequently contains HTML/code snippets.
The HTTP parsing layer must not interfere with such content.
"""
test_messages = [
{
"role": "user",
"content": "<script>alert('hello')</script>",
},
{
"role": "user",
"content": "<script> test </script>",
},
{
"role": "user",
"content": "Can you explain what <script> tags do in HTML?",
},
{
"role": "user",
"content": "Here is code: <div><script src='app.js'></script></div>",
},
{
"role": "user",
"content": "<img onerror='alert(1)' src='x'>",
},
{
"role": "user",
"content": "<iframe src='https://example.com'></iframe>",
},
]
for msg in test_messages:
test_payload = {
"model": "gpt-4o",
"messages": [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "Hello! How can I help?"},
msg,
],
}
mock_request = MagicMock()
mock_request.body = AsyncMock(return_value=orjson.dumps(test_payload))
mock_request.headers = {"content-type": "application/json"}
mock_request.scope = {}
result = await _read_request_body(mock_request)
assert result["model"] == "gpt-4o"
assert len(result["messages"]) == 3
assert result["messages"][2]["content"] == msg["content"], (
f"Message content with HTML was modified during parsing: "
f"expected={msg['content']!r}, got={result['messages'][2]['content']!r}"
)

View file

@ -986,3 +986,146 @@ class TestContentFilterGuardrail:
assert detail.get("category") == "harm_toxic_abuse"
else:
assert "harm_toxic_abuse" in str(detail)
async def test_html_tags_in_messages_not_blocked(self):
"""
Test that HTML tags like <script> in LLM message content are NOT blocked
by the content filter guardrail.
Regression test for GitHub issue #20441:
https://github.com/BerriAI/litellm/issues/20441
LLM message content is not rendered as HTML, so HTML tags should be
treated as plain text and should pass through without being blocked.
"""
# Set up a guardrail with all prebuilt patterns enabled as BLOCK
patterns = [
ContentFilterPattern(
pattern_type="prebuilt",
pattern_name="us_ssn",
action=ContentFilterAction.BLOCK,
),
ContentFilterPattern(
pattern_type="prebuilt",
pattern_name="email",
action=ContentFilterAction.BLOCK,
),
ContentFilterPattern(
pattern_type="prebuilt",
pattern_name="credit_card",
action=ContentFilterAction.BLOCK,
),
]
guardrail = ContentFilterGuardrail(
guardrail_name="test-html-tags",
patterns=patterns,
)
# Messages containing <script> and other HTML tags should NOT be blocked
html_messages = [
"<script>alert('hello')</script>",
"<script> test </script>",
"Can you explain what <script> tags do in HTML?",
"Here is some code: <div><script src='app.js'></script></div>",
"<img onerror='alert(1)' src='x'>",
"<iframe src='https://example.com'></iframe>",
"The <style> and <script> elements are important in HTML",
"<a href='javascript:void(0)'>click me</a>",
]
for message in html_messages:
# Should NOT raise HTTPException
result = await guardrail.apply_guardrail(
inputs={"texts": [message]},
request_data={},
input_type="request",
)
processed_texts = result.get("texts", [])
assert len(processed_texts) == 1
# Content should pass through unchanged (no HTML tags are patterns)
assert processed_texts[0] == message, (
f"Message containing HTML was unexpectedly modified: "
f"input={message!r}, output={processed_texts[0]!r}"
)
@pytest.mark.asyncio
async def test_script_tag_not_blocked_with_blocked_words(self):
"""
Test that <script> tags are not accidentally caught by blocked words
unless explicitly configured.
Regression test for GitHub issue #20441.
"""
blocked_words = [
BlockedWord(
keyword="confidential",
action=ContentFilterAction.BLOCK,
),
BlockedWord(
keyword="secret_project",
action=ContentFilterAction.BLOCK,
),
]
guardrail = ContentFilterGuardrail(
guardrail_name="test-script-not-blocked",
blocked_words=blocked_words,
)
# <script> should not be caught by unrelated blocked words
script_messages = [
"<script>alert('test')</script>",
"How do I use <script> tags in HTML?",
"<script src='app.js'></script>",
]
for message in script_messages:
result = await guardrail.apply_guardrail(
inputs={"texts": [message]},
request_data={},
input_type="request",
)
processed_texts = result.get("texts", [])
assert len(processed_texts) == 1
assert processed_texts[0] == message
def test_no_builtin_pattern_matches_script_tag(self):
"""
Test that NONE of the prebuilt patterns in patterns.json match
the string '<script>' or common HTML tags.
This is a safeguard to ensure that future pattern additions
do not accidentally block legitimate LLM content containing
HTML/code snippets.
Regression test for GitHub issue #20441.
"""
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.patterns import (
PREBUILT_PATTERNS,
get_compiled_pattern,
)
html_test_strings = [
"<script>alert('xss')</script>",
"<script> test </script>",
"<script src='app.js'></script>",
"<img onerror='alert(1)' src='x'>",
"<iframe src='https://example.com'></iframe>",
"<style>body { color: red; }</style>",
"<div onclick='alert(1)'>click</div>",
]
for pattern_name in PREBUILT_PATTERNS:
compiled = get_compiled_pattern(pattern_name)
for test_string in html_test_strings:
match = compiled.search(test_string)
if match:
# Some patterns may legitimately match substrings
# (e.g., URL pattern matching src='https://...')
# but they should not match the script/HTML tag itself
matched_text = match.group()
assert "<script" not in matched_text.lower(), (
f"Pattern '{pattern_name}' matched '<script>' in "
f"test string: {test_string!r}. "
f"LLM message content should not be blocked for HTML tags."
)

View file

@ -0,0 +1,136 @@
"""
Tests that API keys are masked in error responses.
When an invalid/malformed API key is sent (e.g., with a leading space or
wrong prefix), the error response must NOT return the key in plain text.
Instead, it should show only the first 4 and last 4 characters with ****
in the middle.
"""
import pytest
class TestKeyMaskingInAuthErrors:
"""Test that user_api_key_auth masks keys in validation error messages."""
def test_assert_message_masks_key_without_sk_prefix(self):
"""
When a key doesn't start with 'sk-', the AssertionError message
should contain a masked version, not the full key.
"""
from litellm.proxy.auth.auth_utils import abbreviate_api_key
# Simulate the logic from user_api_key_auth.py
api_key = "my-secret-api-key-1234567890abcdef"
_masked_key = (
"{}****{}".format(api_key[:4], api_key[-4:])
if len(api_key) > 8
else "****"
)
# The masked key should NOT contain the full original key
assert api_key not in _masked_key
# Should show first 4 and last 4 chars
assert _masked_key == "my-s****cdef"
def test_assert_message_masks_key_with_leading_space(self):
"""
Reported case: key with leading space like ' sk-abc123...'
"""
api_key = " sk-abc123def456ghi789jkl012mno345pqr"
_masked_key = (
"{}****{}".format(api_key[:4], api_key[-4:])
if len(api_key) > 8
else "****"
)
assert api_key not in _masked_key
assert _masked_key == " sk-****5pqr"
def test_assert_message_masks_short_key(self):
"""Short keys (<=8 chars) should be fully masked."""
api_key = "short"
_masked_key = (
"{}****{}".format(api_key[:4], api_key[-4:])
if len(api_key) > 8
else "****"
)
assert _masked_key == "****"
def test_key_not_starting_with_sk_raises_masked_error(self):
"""
Verify the assert message format contains masked key, not the original.
Note: Python's AssertionError str(e) includes the expression + message,
but the *message* part (which is what gets passed to ProxyException)
should only contain the masked key.
"""
api_key = "bad-key-format-1234567890abcdefghijklmnop"
_masked_key = (
"{}****{}".format(api_key[:4], api_key[-4:])
if len(api_key) > 8
else "****"
)
# Build the same message string that user_api_key_auth.py would produce
error_message = "LiteLLM Virtual Key expected. Received={}, expected to start with 'sk-'.".format(
_masked_key
)
# The full key must NOT appear in the message
assert api_key not in error_message
# The masked version should appear
assert _masked_key in error_message
# Should still have helpful context
assert "expected to start with 'sk-'" in error_message
class TestKeyMaskingInKeyManagement:
"""Test that key_management_endpoints masks keys in validation errors."""
def test_invalid_key_format_error_is_masked(self):
"""
When creating a key that doesn't start with 'sk-', the error
should not include the full key value.
"""
key_value = "bad-prefix-1234567890abcdefghijklmnop"
_masked = (
"{}****{}".format(key_value[:4], key_value[-4:])
if len(key_value) > 8
else "****"
)
error_msg = f"Invalid key format. LiteLLM Virtual Key must start with 'sk-'. Received: {_masked}"
# Full key must not appear
assert key_value not in error_msg
# Masked version should appear
assert _masked in error_msg
assert "bad-****mnop" in error_msg
class TestPresidioErrorSanitization:
"""Test that Presidio errors don't leak request text containing keys."""
def test_analyze_text_error_does_not_leak_text(self):
"""
If Presidio analyzer fails, the error message should NOT contain
the original text that was being analyzed.
"""
# Simulate what happens: user message contains an API key,
# Presidio fails, error message should be sanitized
original_text = "Please use this key: sk-secret1234567890abcdefghijklmnop"
# The sanitized exception from our fix
sanitized_error = f"Presidio PII analysis failed: ConnectionError"
assert original_text not in sanitized_error
assert "sk-secret1234567890abcdefghijklmnop" not in sanitized_error
def test_anonymize_text_error_does_not_leak_text(self):
"""
If Presidio anonymizer fails, the error should be sanitized.
"""
sanitized_error = f"Presidio PII anonymization failed: ClientError"
assert "sk-" not in sanitized_error
assert "api_key" not in sanitized_error