mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-09 22:31:41 +00:00
Merge pull request #21044 from BerriAI/litellm_oss_staging_02_07_20262
Litellm oss staging 02 07 20262
This commit is contained in:
commit
e85da33240
17 changed files with 768 additions and 69 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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)` |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
)
|
||||
|
|
|
|||
136
tests/test_litellm/proxy/test_api_key_masking_in_errors.py
Normal file
136
tests/test_litellm/proxy/test_api_key_masking_in_errors.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue