Merge remote-tracking branch 'origin' into litellm_ui_chat_endpoint_search

This commit is contained in:
yuneng-jiang 2025-12-05 21:06:59 -08:00
commit 924cd62a7f
101 changed files with 3966 additions and 459 deletions

View file

@ -274,8 +274,6 @@ echo 'LITELLM_MASTER_KEY="sk-1234"' > .env
# password generator to get a random hash for litellm salt key
echo 'LITELLM_SALT_KEY="sk-1234"' >> .env
source .env
# Start
docker compose up
```

View file

@ -2006,3 +2006,34 @@ curl -L -X POST 'http://localhost:4000/v1/chat/completions' \
</TabItem>
</Tabs>
### Image Generation Pricing
Gemini image generation models (like `gemini-3-pro-image-preview`) return `image_tokens` in the response usage. These tokens are priced differently from text tokens:
| Token Type | Price per 1M tokens | Price per token |
|------------|---------------------|-----------------|
| Text output | $12 | $0.000012 |
| Image output | $120 | $0.00012 |
The number of image tokens depends on the output resolution:
| Resolution | Tokens per image | Cost per image |
|------------|------------------|----------------|
| 1K-2K (1024x1024 to 2048x2048) | 1,120 | $0.134 |
| 4K (4096x4096) | 2,000 | $0.24 |
LiteLLM automatically calculates costs using `output_cost_per_image_token` from the model pricing configuration.
**Example response usage:**
```json
{
"completion_tokens_details": {
"reasoning_tokens": 225,
"text_tokens": 0,
"image_tokens": 1120
}
}
```
For more details, see [Google's Gemini pricing documentation](https://ai.google.dev/gemini-api/docs/pricing).

View file

@ -26,8 +26,6 @@ echo 'LITELLM_MASTER_KEY="sk-1234"' > .env
# password generator to get a random hash for litellm salt key
echo 'LITELLM_SALT_KEY="sk-1234"' >> .env
source .env
# Start
docker compose up
```
@ -1072,4 +1070,4 @@ A: We explored MySQL but that was hard to maintain and led to bugs for customers
**Q: If there is Postgres downtime, how does LiteLLM react? Does it fail-open or is there API downtime?**
A: You can gracefully handle DB unavailability if it's on your VPC. See our production guide for more details: [Gracefully Handle DB Unavailability](https://docs.litellm.ai/docs/proxy/prod#6-if-running-litellm-on-vpc-gracefully-handle-db-unavailability)
A: You can gracefully handle DB unavailability if it's on your VPC. See our production guide for more details: [Gracefully Handle DB Unavailability](https://docs.litellm.ai/docs/proxy/prod#6-if-running-litellm-on-vpc-gracefully-handle-db-unavailability)

View file

@ -52,8 +52,6 @@ echo 'LITELLM_MASTER_KEY="sk-1234"' > .env
# password generator to get a random hash for litellm salt key
echo 'LITELLM_SALT_KEY="sk-1234"' >> .env
source .env
# Start
docker compose up
```

View file

@ -175,7 +175,37 @@ general_settings:
litellm --config /path/to/config.yaml
```
#### 2. Create Keys with Priority Levels
### Set priority on either a team or a key
Priority can be set at either the **team level** or **key level**. Team-level priority takes precedence over key-level priority.
**Option A: Set Priority on Team (Recommended)**
All keys within a team will inherit the team's priority. This is useful when you want all keys for a specific environment or project to have the same priority.
```bash
curl -X POST 'http://0.0.0.0:4000/team/new' \
-H 'Authorization: Bearer sk-1234' \
-H 'Content-Type: application/json' \
-d '{
"team_alias": "production-team",
"metadata": {"priority": "prod"}
}'
```
Create a key for this team:
```bash
curl -X POST 'http://0.0.0.0:4000/key/generate' \
-H 'Authorization: Bearer sk-1234' \
-H 'Content-Type: application/json' \
-d '{
"team_id": "team-id-from-previous-response"
}'
```
**Option B: Set Priority on Individual Keys**
Set priority directly on the key. This is useful when you need fine-grained control per key.
**Production Key:**
```bash
@ -205,7 +235,7 @@ curl -X POST 'http://0.0.0.0:4000/key/generate' \
-d '{}'
```
**Expected Response for both:**
**Expected Response:**
```json
{
"key": "sk-...",
@ -214,6 +244,11 @@ curl -X POST 'http://0.0.0.0:4000/key/generate' \
}
```
**Priority Resolution Order:**
1. If key belongs to a team with `metadata.priority` set → use team priority
2. Else if key has `metadata.priority` set → use key priority
3. Else → use `default_priority` from config
#### 3. Test Priority Allocation
**Test Production Key (should get 9 RPM):**

View file

@ -81,6 +81,85 @@ for event in stream:
f.write(image_bytes)
```
#### Image Generation (Non-streaming)
Image generation is supported for models that generate images. Generated images are returned in the `output` array with `type: "image_generation_call"`.
**Gemini (Google AI Studio):**
```python showLineNumbers title="Gemini Image Generation"
import litellm
import base64
# Gemini image generation models don't require tools parameter
response = litellm.responses(
model="gemini/gemini-2.5-flash-image",
input="Generate a cute cat playing with yarn"
)
# Access generated images from output
for item in response.output:
if item.type == "image_generation_call":
# item.result contains pure base64 (no data: prefix)
image_bytes = base64.b64decode(item.result)
# Save the image
with open(f"generated_{item.id}.png", "wb") as f:
f.write(image_bytes)
print(f"Image saved: generated_{response.output[0].id}.png")
```
**OpenAI:**
```python showLineNumbers title="OpenAI Image Generation"
import litellm
import base64
# OpenAI models require tools parameter for image generation
response = litellm.responses(
model="openai/gpt-4o",
input="Generate a futuristic city at sunset",
tools=[{"type": "image_generation"}]
)
# Access generated images from output
for item in response.output:
if item.type == "image_generation_call":
image_bytes = base64.b64decode(item.result)
with open(f"generated_{item.id}.png", "wb") as f:
f.write(image_bytes)
```
**Response Format:**
When image generation is successful, the response contains:
```json
{
"id": "resp_abc123",
"status": "completed",
"output": [
{
"type": "image_generation_call",
"id": "resp_abc123_img_0",
"status": "completed",
"result": "iVBORw0KGgo..." // Pure base64 string (no data: prefix)
}
]
}
```
**Supported Models:**
| Provider | Models | Requires `tools` Parameter |
|----------|--------|---------------------------|
| Google AI Studio | `gemini/gemini-2.5-flash-image` | ❌ No |
| Vertex AI | `vertex_ai/gemini-2.5-flash-image-preview` | ❌ No |
| OpenAI | `gpt-4o`, `gpt-4o-mini`, `gpt-4.1`, `gpt-4.1-mini`, `gpt-4.1-nano`, `o3` | ✅ Yes |
| AWS Bedrock | Stability AI, Amazon Nova Canvas models | Model-specific |
| Fal AI | Various image generation models | Check model docs |
**Note:** The `result` field contains pure base64-encoded image data without the `data:image/png;base64,` prefix. You must decode it with `base64.b64decode()` before saving.
#### GET a Response
```python showLineNumbers title="Get Response by ID"
import litellm

Binary file not shown.

View file

@ -0,0 +1,42 @@
-- CreateTable
CREATE TABLE "LiteLLM_DailyEndUserSpend" (
"id" TEXT NOT NULL,
"end_user_id" TEXT,
"date" TEXT NOT NULL,
"api_key" TEXT NOT NULL,
"model" TEXT,
"model_group" TEXT,
"custom_llm_provider" TEXT,
"mcp_namespaced_tool_name" TEXT,
"prompt_tokens" BIGINT NOT NULL DEFAULT 0,
"completion_tokens" BIGINT NOT NULL DEFAULT 0,
"cache_read_input_tokens" BIGINT NOT NULL DEFAULT 0,
"cache_creation_input_tokens" BIGINT NOT NULL DEFAULT 0,
"spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0,
"api_requests" BIGINT NOT NULL DEFAULT 0,
"successful_requests" BIGINT NOT NULL DEFAULT 0,
"failed_requests" BIGINT NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LiteLLM_DailyEndUserSpend_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "LiteLLM_DailyEndUserSpend_date_idx" ON "LiteLLM_DailyEndUserSpend"("date");
-- CreateIndex
CREATE INDEX "LiteLLM_DailyEndUserSpend_end_user_id_idx" ON "LiteLLM_DailyEndUserSpend"("end_user_id");
-- CreateIndex
CREATE INDEX "LiteLLM_DailyEndUserSpend_api_key_idx" ON "LiteLLM_DailyEndUserSpend"("api_key");
-- CreateIndex
CREATE INDEX "LiteLLM_DailyEndUserSpend_model_idx" ON "LiteLLM_DailyEndUserSpend"("model");
-- CreateIndex
CREATE INDEX "LiteLLM_DailyEndUserSpend_mcp_namespaced_tool_name_idx" ON "LiteLLM_DailyEndUserSpend"("mcp_namespaced_tool_name");
-- CreateIndex
CREATE UNIQUE INDEX "LiteLLM_DailyEndUserSpend_end_user_id_date_api_key_model_cu_key" ON "LiteLLM_DailyEndUserSpend"("end_user_id", "date", "api_key", "model", "custom_llm_provider", "mcp_namespaced_tool_name");

View file

@ -465,6 +465,34 @@ model LiteLLM_DailyOrganizationSpend {
@@index([mcp_namespaced_tool_name])
}
// Track daily end user (customer) spend metrics per model and key
model LiteLLM_DailyEndUserSpend {
id String @id @default(uuid())
end_user_id String?
date String
api_key String
model String?
model_group String?
custom_llm_provider String?
mcp_namespaced_tool_name String?
prompt_tokens BigInt @default(0)
completion_tokens BigInt @default(0)
cache_read_input_tokens BigInt @default(0)
cache_creation_input_tokens BigInt @default(0)
spend Float @default(0.0)
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
failed_requests BigInt @default(0)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@unique([end_user_id, date, api_key, model, custom_llm_provider, mcp_namespaced_tool_name])
@@index([date])
@@index([end_user_id])
@@index([api_key])
@@index([model])
@@index([mcp_namespaced_tool_name])
}
// Track daily team spend metrics per model and key
model LiteLLM_DailyTeamSpend {
id String @id @default(uuid())

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "litellm-proxy-extras"
version = "0.4.9"
version = "0.4.10"
description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package."
authors = ["BerriAI"]
readme = "README.md"
@ -22,7 +22,7 @@ requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.commitizen]
version = "0.4.9"
version = "0.4.10"
version_files = [
"pyproject.toml:version",
"../requirements.txt:litellm-proxy-extras==",

View file

@ -520,6 +520,7 @@ perplexity_models: Set = set()
watsonx_models: Set = set()
gemini_models: Set = set()
xai_models: Set = set()
zai_models: Set = set()
deepseek_models: Set = set()
runwayml_models: Set = set()
azure_ai_models: Set = set()
@ -711,6 +712,8 @@ def add_known_models():
text_completion_codestral_models.add(key)
elif value.get("litellm_provider") == "xai":
xai_models.add(key)
elif value.get("litellm_provider") == "zai":
zai_models.add(key)
elif value.get("litellm_provider") == "fal_ai":
fal_ai_models.add(key)
elif value.get("litellm_provider") == "deepseek":
@ -872,6 +875,7 @@ model_list = list(
| gemini_models
| text_completion_codestral_models
| xai_models
| zai_models
| fal_ai_models
| deepseek_models
| azure_ai_models
@ -960,6 +964,7 @@ models_by_provider: dict = {
"aleph_alpha": aleph_alpha_models,
"text-completion-codestral": text_completion_codestral_models,
"xai": xai_models,
"zai": zai_models,
"fal_ai": fal_ai_models,
"deepseek": deepseek_models,
"runwayml": runwayml_models,
@ -1300,6 +1305,7 @@ from .llms.friendliai.chat.transformation import FriendliaiChatConfig
from .llms.jina_ai.embedding.transformation import JinaAIEmbeddingConfig
from .llms.xai.chat.transformation import XAIChatConfig
from .llms.xai.common_utils import XAIModelInfo
from .llms.zai.chat.transformation import ZAIChatConfig
from .llms.aiml.chat.transformation import AIMLChatConfig
from .llms.volcengine.chat.transformation import (
VolcEngineChatConfig as VolcEngineConfig,
@ -1497,10 +1503,46 @@ def set_global_gitlab_config(config: Dict[str, Any]) -> None:
# Lazy loading system for heavy modules to reduce initial import time and memory usage
if TYPE_CHECKING:
from litellm.types.utils import ModelInfo as _ModelInfoType
# Cost calculator functions
cost_per_token: Callable[..., Tuple[float, float]]
completion_cost: Callable[..., float]
response_cost_calculator: Any
modify_integration: Any
# Utils functions - type stubs for truly lazy loaded functions only
# (functions NOT imported via "from .main import *")
get_response_string: Callable[..., str]
supports_function_calling: Callable[..., bool]
supports_web_search: Callable[..., bool]
supports_url_context: Callable[..., bool]
supports_response_schema: Callable[..., bool]
supports_parallel_function_calling: Callable[..., bool]
supports_vision: Callable[..., bool]
supports_audio_input: Callable[..., bool]
supports_audio_output: Callable[..., bool]
supports_system_messages: Callable[..., bool]
supports_reasoning: Callable[..., bool]
acreate: Callable[..., Any]
get_max_tokens: Callable[..., int]
get_model_info: Callable[..., _ModelInfoType]
register_prompt_template: Callable[..., None]
validate_environment: Callable[..., dict]
check_valid_key: Callable[..., bool]
register_model: Callable[..., None]
encode: Callable[..., list]
decode: Callable[..., str]
_calculate_retry_after: Callable[..., float]
_should_retry: Callable[..., bool]
get_supported_openai_params: Callable[..., Optional[list]]
get_api_base: Callable[..., Optional[str]]
get_first_chars_messages: Callable[..., str]
get_provider_fields: Callable[..., List]
get_valid_models: Callable[..., list]
# Response types - truly lazy loaded only (not in main.py or elsewhere)
ModelResponseListIterator: Type[Any]
def __getattr__(name: str) -> Any:

View file

@ -149,6 +149,7 @@ REDIS_UPDATE_BUFFER_KEY = "litellm_spend_update_buffer"
REDIS_DAILY_SPEND_UPDATE_BUFFER_KEY = "litellm_daily_spend_update_buffer"
REDIS_DAILY_TEAM_SPEND_UPDATE_BUFFER_KEY = "litellm_daily_team_spend_update_buffer"
REDIS_DAILY_ORG_SPEND_UPDATE_BUFFER_KEY = "litellm_daily_org_spend_update_buffer"
REDIS_DAILY_END_USER_SPEND_UPDATE_BUFFER_KEY = "litellm_daily_end_user_spend_update_buffer"
REDIS_DAILY_TAG_SPEND_UPDATE_BUFFER_KEY = "litellm_daily_tag_spend_update_buffer"
MAX_REDIS_BUFFER_DEQUEUE_COUNT = int(os.getenv("MAX_REDIS_BUFFER_DEQUEUE_COUNT", 100))
MAX_SIZE_IN_MEMORY_QUEUE = int(os.getenv("MAX_SIZE_IN_MEMORY_QUEUE", 10000))
@ -538,6 +539,7 @@ openai_compatible_endpoints: List = [
"https://api.friendli.ai/serverless/v1",
"api.sambanova.ai/v1",
"api.x.ai/v1",
"ollama.com",
"api.galadriel.ai/v1",
"api.llama.com/compat/v1/",
"api.featherless.ai/v1",

View file

@ -860,9 +860,9 @@ def completion_cost( # noqa: PLR0915
or isinstance(completion_response, dict)
): # tts returns a custom class
if isinstance(completion_response, dict):
usage_obj: Optional[Union[dict, Usage]] = (
completion_response.get("usage", {})
)
usage_obj: Optional[
Union[dict, Usage]
] = completion_response.get("usage", {})
else:
usage_obj = getattr(completion_response, "usage", {})
if isinstance(usage_obj, BaseModel) and not _is_known_usage_objects(
@ -1066,13 +1066,14 @@ def completion_cost( # noqa: PLR0915
# If model is like "tavily-search", construct "tavily/search" for cost lookup
search_model = f"{custom_llm_provider}/search"
prompt_cost, completion_cost_result = (
search_provider_cost_per_query(
model=search_model,
custom_llm_provider=custom_llm_provider,
number_of_queries=number_of_queries,
optional_params=optional_params,
)
(
prompt_cost,
completion_cost_result,
) = search_provider_cost_per_query(
model=search_model,
custom_llm_provider=custom_llm_provider,
number_of_queries=number_of_queries,
optional_params=optional_params,
)
# Return the total cost (prompt_cost + completion_cost, but for search it's just prompt_cost)
@ -1080,11 +1081,13 @@ def completion_cost( # noqa: PLR0915
# Apply discount
original_cost = _final_cost
_final_cost, discount_percent, discount_amount = (
_apply_cost_discount(
base_cost=_final_cost,
custom_llm_provider=custom_llm_provider,
)
(
_final_cost,
discount_percent,
discount_amount,
) = _apply_cost_discount(
base_cost=_final_cost,
custom_llm_provider=custom_llm_provider,
)
# Store cost breakdown in logging object if available
@ -1329,9 +1332,8 @@ def response_cost_calculator(
response_cost = 0.0
else:
if isinstance(response_object, BaseModel):
response_object._hidden_params["optional_params"] = optional_params
if hasattr(response_object, "_hidden_params"):
response_object._hidden_params["optional_params"] = optional_params
provider_response_cost = get_response_cost_from_hidden_params(
response_object._hidden_params
)

View file

@ -229,6 +229,9 @@ def get_llm_provider( # noqa: PLR0915
elif endpoint == "api.deepseek.com/v1":
custom_llm_provider = "deepseek"
dynamic_api_key = get_secret_str("DEEPSEEK_API_KEY")
elif endpoint == "ollama.com":
custom_llm_provider = "ollama"
dynamic_api_key = get_secret_str("OLLAMA_API_KEY")
elif endpoint == "https://api.friendli.ai/serverless/v1":
custom_llm_provider = "friendliai"
dynamic_api_key = get_secret_str(
@ -469,11 +472,13 @@ def _get_openai_compatible_provider_info( # noqa: PLR0915
model = model.split("/", 1)[1]
# Check JSON providers FIRST (before hardcoded ones)
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
from litellm.llms.openai_like.dynamic_config import create_config_class
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
if JSONProviderRegistry.exists(custom_llm_provider):
provider_config = JSONProviderRegistry.get(custom_llm_provider)
if provider_config is None:
raise ValueError(f"Provider {custom_llm_provider} not found")
config_class = create_config_class(provider_config)
api_base, dynamic_api_key = config_class()._get_openai_compatible_provider_info(
api_base, api_key
@ -556,6 +561,13 @@ def _get_openai_compatible_provider_info( # noqa: PLR0915
or "https://api.studio.nebius.ai/v1"
) # type: ignore
dynamic_api_key = api_key or get_secret_str("NEBIUS_API_KEY")
elif custom_llm_provider == "ollama":
api_base = (
api_base
or get_secret("OLLAMA_API_BASE")
or "http://localhost:11434"
) # type: ignore
dynamic_api_key = api_key or get_secret_str("OLLAMA_API_KEY")
elif (custom_llm_provider == "ai21_chat") or (
custom_llm_provider == "ai21" and model in litellm.ai21_chat_models
):
@ -675,12 +687,12 @@ def _get_openai_compatible_provider_info( # noqa: PLR0915
api_base, api_key
)
elif custom_llm_provider == "zai":
api_base = (
api_base
or get_secret_str("ZAI_API_BASE")
or "https://api.z.ai/api/paas/v4"
(
api_base,
dynamic_api_key,
) = litellm.ZAIChatConfig()._get_openai_compatible_provider_info(
api_base, api_key
)
dynamic_api_key = api_key or get_secret_str("ZAI_API_KEY")
elif custom_llm_provider == "together_ai":
api_base = (
api_base

View file

@ -583,9 +583,11 @@ def generic_cost_per_token(
reasoning_tokens = completion_tokens_details["reasoning_tokens"]
image_tokens = completion_tokens_details["image_tokens"]
if text_tokens == 0:
# Only assume all tokens are text if there's NO breakdown at all
# If image_tokens, audio_tokens, or reasoning_tokens exist, respect text_tokens=0
has_token_breakdown = image_tokens > 0 or audio_tokens > 0 or reasoning_tokens > 0
if text_tokens == 0 and not has_token_breakdown:
text_tokens = usage.completion_tokens
if text_tokens == usage.completion_tokens:
is_text_tokens_total = True
## TEXT COST
completion_cost = float(text_tokens) * completion_base_cost

View file

@ -737,6 +737,7 @@ class CustomStreamWrapper:
or (
"tool_calls" in model_response.choices[0].delta
and model_response.choices[0].delta["tool_calls"] is not None
and len(model_response.choices[0].delta["tool_calls"]) > 0
)
or (
"function_call" in model_response.choices[0].delta

View file

@ -960,7 +960,10 @@ class AmazonConverseConfig(BaseConfig):
bedrock_tools = _bedrock_tools_pt(filtered_tools)
# Set anthropic_beta in additional_request_params if we have any beta features
if anthropic_beta_list:
# ONLY apply to Anthropic/Claude models - other models (e.g., Qwen, Llama) don't support this field
# and will error with "unknown variant anthropic_beta" if included
base_model = BedrockModelInfo.get_base_model(model)
if anthropic_beta_list and base_model.startswith("anthropic"):
# Remove duplicates while preserving order
unique_betas = []
seen = set()

View file

@ -416,15 +416,34 @@ class OCIChatConfig(BaseConfig):
"Please install it with: pip install cryptography"
) from e
# Handle oci_key - it should be a string (PEM content)
oci_key_content = None
if oci_key:
if isinstance(oci_key, str):
oci_key_content = oci_key
# Fix common issues with PEM content
# Replace escaped newlines with actual newlines
oci_key_content = oci_key_content.replace("\\n", "\n")
# Ensure proper line endings
if "\r\n" in oci_key_content:
oci_key_content = oci_key_content.replace("\r\n", "\n")
else:
raise OCIError(
status_code=400,
message=f"oci_key must be a string containing the PEM private key content. "
f"Got type: {type(oci_key).__name__}",
)
private_key = (
load_private_key_from_str(oci_key)
if oci_key
load_private_key_from_str(oci_key_content)
if oci_key_content
else load_private_key_from_file(oci_key_file) if oci_key_file else None
)
if private_key is None:
raise Exception(
"Private key is required for OCI authentication. Please provide either oci_key or oci_key_file."
raise OCIError(
status_code=400,
message="Private key is required for OCI authentication. Please provide either oci_key or oci_key_file.",
)
signature = private_key.sign(

View file

@ -164,7 +164,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation):
for tool_call_idx, tool_call in enumerate(tool_calls):
if isinstance(tool_call, dict):
# Add the full tool call object to the list
tool_calls_to_check.append(ChatCompletionToolParam(**tool_call))
tool_calls_to_check.append(cast(ChatCompletionToolParam, tool_call))
tool_call_task_mappings.append((msg_idx, int(tool_call_idx)))
async def _apply_guardrail_responses_to_input_texts(
@ -380,20 +380,20 @@ class OpenAIChatCompletionsHandler(BaseTranslation):
if isinstance(content, str):
# String content - accumulate for this choice
key = (choice_idx, None)
if key not in combined_texts:
combined_texts[key] = ""
combined_texts[key] += content
str_key: Tuple[int, Optional[int]] = (choice_idx, None)
if str_key not in combined_texts:
combined_texts[str_key] = ""
combined_texts[str_key] += content
elif isinstance(content, list):
# List content - accumulate for each content item
for content_idx, content_item in enumerate(content):
text_str = content_item.get("text")
if text_str:
key = (choice_idx, content_idx)
if key not in combined_texts:
combined_texts[key] = ""
combined_texts[key] += text_str
list_key: Tuple[int, Optional[int]] = (choice_idx, content_idx)
if list_key not in combined_texts:
combined_texts[list_key] = ""
combined_texts[list_key] += text_str
# Step 2: Create lists for guardrail processing
texts_to_check: List[str] = []
@ -401,9 +401,9 @@ class OpenAIChatCompletionsHandler(BaseTranslation):
task_mappings: List[Tuple[int, Optional[int]]] = []
# Track (choice_index, content_index) for each combined text
for (choice_idx, content_idx), combined_text in combined_texts.items():
for (map_choice_idx, map_content_idx), combined_text in combined_texts.items():
texts_to_check.append(combined_text)
task_mappings.append((choice_idx, content_idx))
task_mappings.append((map_choice_idx, map_content_idx))
# Step 3: Apply guardrail to all combined texts in batch
if texts_to_check:
@ -503,7 +503,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation):
# Determine content source and tool calls based on choice type
content = None
tool_calls = None
tool_calls: Optional[List[Any]] = None
if isinstance(choice, litellm.Choices):
content = choice.message.content
tool_calls = choice.message.tool_calls
@ -686,15 +686,15 @@ class OpenAIChatCompletionsHandler(BaseTranslation):
if isinstance(content, str):
# String content
key = (choice_idx_in_response, None)
if key in guardrail_map:
if key not in already_set:
str_key: Tuple[int, Optional[int]] = (choice_idx_in_response, None)
if str_key in guardrail_map:
if str_key not in already_set:
# First chunk - set the complete guardrailed text
if isinstance(choice, litellm.StreamingChoices):
choice.delta.content = guardrail_map[key]
choice.delta.content = guardrail_map[str_key]
elif isinstance(choice, litellm.Choices):
choice.message.content = guardrail_map[key]
already_set[key] = True
choice.message.content = guardrail_map[str_key]
already_set[str_key] = True
else:
# Subsequent chunks - clear the content
if isinstance(choice, litellm.StreamingChoices):
@ -706,12 +706,12 @@ class OpenAIChatCompletionsHandler(BaseTranslation):
# List content - handle each content item
for content_idx, content_item in enumerate(content):
if "text" in content_item:
key = (choice_idx_in_response, content_idx)
if key in guardrail_map:
if key not in already_set:
list_key: Tuple[int, Optional[int]] = (choice_idx_in_response, content_idx)
if list_key in guardrail_map:
if list_key not in already_set:
# First chunk - set the complete guardrailed text
content_item["text"] = guardrail_map[key]
already_set[key] = True
content_item["text"] = guardrail_map[list_key]
already_set[list_key] = True
else:
# Subsequent chunks - clear the text
content_item["text"] = ""

View file

@ -19,11 +19,11 @@ def create_config_class(provider: SimpleProviderConfig):
"""Generate config class dynamically from JSON configuration"""
# Choose base class
base_class = (
base_class: type = (
OpenAIGPTConfig if provider.base_class == "openai_gpt" else OpenAILikeChatConfig
)
class JSONProviderConfig(base_class):
class JSONProviderConfig(base_class): # type: ignore[valid-type,misc]
@overload
def _transform_messages(
self, messages: List[AllMessageValues], model: str, is_async: Literal[True]
@ -87,6 +87,9 @@ def create_config_class(provider: SimpleProviderConfig):
if not api_base:
api_base = provider.base_url
if api_base is None:
raise ValueError(f"api_base is required for provider {provider.slug}")
if not api_base.endswith("/chat/completions"):
api_base = f"{api_base}/chat/completions"

View file

@ -116,7 +116,7 @@ def _process_gemini_image(
is not None
):
file_data = FileDataType(file_uri=image_url, mime_type=image_type)
part: PartType = {"file_data": file_data}
part = {"file_data": file_data}
if media_resolution_enum is not None and model is not None:
from .vertex_and_google_ai_studio_gemini import VertexGeminiConfig
@ -129,7 +129,7 @@ def _process_gemini_image(
image = convert_to_anthropic_image_obj(image_url, format=format)
_blob: BlobType = {"data": image["data"], "mime_type": image["media_type"]}
part: PartType = {"inline_data": cast(BlobType, _blob)}
part = {"inline_data": cast(BlobType, _blob)}
if media_resolution_enum is not None and model is not None:
from .vertex_and_google_ai_studio_gemini import VertexGeminiConfig

View file

@ -1085,24 +1085,26 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
def _extract_thinking_blocks_from_parts(
self, parts: List[HttpxPartType]
) -> List[ChatCompletionThinkingBlock]:
"""Extract thinking blocks from parts if present"""
"""Extract thinking blocks from parts if present.
Per Google's docs (https://ai.google.dev/gemini-api/docs/thinking):
- Parts with `thought: true` contain thinking/reasoning content
- `thoughtSignature` is a separate token for multi-turn context preservation,
it does NOT indicate that the content is thinking (a part can have
thoughtSignature without thought: true, e.g., function calls)
"""
thinking_blocks: List[ChatCompletionThinkingBlock] = []
for part in parts:
if "thoughtSignature" in part:
part_copy = part.copy()
part_copy.pop("thoughtSignature")
text_content = part_copy.get("text")
if isinstance(text_content, str) and text_content.strip() == "":
continue
thinking_blocks.append(
ChatCompletionThinkingBlock(
type="thinking",
thinking=json.dumps(part_copy),
signature=part["thoughtSignature"],
)
)
if part.get("thought") is True:
thinking_text = part.get("text", "")
block: ChatCompletionThinkingBlock = {
"type": "thinking",
"thinking": thinking_text,
}
signature = part.get("thoughtSignature")
if signature is not None:
block["signature"] = signature
thinking_blocks.append(block)
return thinking_blocks
def _extract_image_response_from_parts(

View file

@ -140,7 +140,11 @@ class VertexAIGeminiImageGenerationConfig(BaseImageGenerationConfig, VertexLLM):
if not vertex_project or not vertex_location:
raise ValueError("vertex_project and vertex_location are required for Vertex AI")
base_url = f"https://{vertex_location}-aiplatform.googleapis.com"
# Handle global location differently (no region prefix in URL)
if vertex_location == "global":
base_url = "https://aiplatform.googleapis.com"
else:
base_url = f"https://{vertex_location}-aiplatform.googleapis.com"
return f"{base_url}/v1/projects/{vertex_project}/locations/{vertex_location}/publishers/google/models/{model_name}:generateContent"

View file

@ -220,7 +220,7 @@ class VertexAITextToSpeechConfig(BaseTextToSpeechConfig, VertexBase):
Returns:
Tuple of (mapped_voice_str, mapped_params)
"""
mapped_params = {}
mapped_params: Dict[str, Any] = {}
##########################################################
# Map voice using helper

View file

View file

View file

@ -0,0 +1,33 @@
from typing import Optional, Tuple
from litellm.secret_managers.main import get_secret_str
from ...openai.chat.gpt_transformation import OpenAIGPTConfig
ZAI_API_BASE = "https://api.z.ai/api/paas/v4"
class ZAIChatConfig(OpenAIGPTConfig):
@property
def custom_llm_provider(self) -> Optional[str]:
return "zai"
def _get_openai_compatible_provider_info(
self, api_base: Optional[str], api_key: Optional[str]
) -> Tuple[Optional[str], Optional[str]]:
api_base = api_base or get_secret_str("ZAI_API_BASE") or ZAI_API_BASE
dynamic_api_key = api_key or get_secret_str("ZAI_API_KEY")
return api_base, dynamic_api_key
def get_supported_openai_params(self, model: str) -> list:
return [
"max_tokens",
"stream",
"stream_options",
"temperature",
"top_p",
"stop",
"tools",
"tool_choice",
]

View file

@ -3503,6 +3503,9 @@ def completion( # type: ignore # noqa: PLR0915
or get_secret("OLLAMA_API_BASE")
or "http://localhost:11434"
)
if api_key is not None and "Authorization" not in headers:
headers["Authorization"] = f"Bearer {api_key}"
response = base_llm_http_handler.completion(
model=model,
stream=stream,
@ -3536,6 +3539,9 @@ def completion( # type: ignore # noqa: PLR0915
or os.environ.get("OLLAMA_API_KEY")
or litellm.api_key
)
if api_key is not None and "Authorization" not in headers:
headers["Authorization"] = f"Bearer {api_key}"
response = base_llm_http_handler.completion(
model=model,

View file

@ -5164,6 +5164,19 @@
"supports_function_calling": true,
"supports_tool_choice": true
},
"azure_ai/mistral-large-3": {
"input_cost_per_token": 5e-07,
"litellm_provider": "azure_ai",
"max_input_tokens": 256000,
"max_output_tokens": 8191,
"max_tokens": 8191,
"mode": "chat",
"output_cost_per_token": 1.5e-06,
"source": "https://azure.microsoft.com/en-us/blog/introducing-mistral-large-3-in-microsoft-foundry-open-capable-and-ready-for-production-workloads/",
"supports_function_calling": true,
"supports_tool_choice": true,
"supports_vision": true
},
"azure_ai/mistral-medium-2505": {
"input_cost_per_token": 4e-07,
"litellm_provider": "azure_ai",
@ -12134,6 +12147,7 @@
"max_tokens": 65536,
"mode": "image_generation",
"output_cost_per_image": 0.134,
"output_cost_per_image_token": 1.2e-04,
"output_cost_per_token": 1.2e-05,
"output_cost_per_token_batches": 6e-06,
"source": "https://ai.google.dev/gemini-api/docs/pricing",
@ -13871,6 +13885,7 @@
"max_tokens": 65536,
"mode": "image_generation",
"output_cost_per_image": 0.134,
"output_cost_per_image_token": 1.2e-04,
"output_cost_per_token": 1.2e-05,
"rpm": 1000,
"tpm": 4000000,
@ -18745,6 +18760,21 @@
"supports_response_schema": true,
"supports_tool_choice": true
},
"mistral/mistral-large-3": {
"input_cost_per_token": 5e-07,
"litellm_provider": "mistral",
"max_input_tokens": 256000,
"max_output_tokens": 8191,
"max_tokens": 8191,
"mode": "chat",
"output_cost_per_token": 1.5e-06,
"source": "https://docs.mistral.ai/models/mistral-large-3-25-12",
"supports_assistant_prefill": true,
"supports_function_calling": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true
},
"mistral/mistral-medium": {
"input_cost_per_token": 2.7e-06,
"litellm_provider": "mistral",
@ -25774,6 +25804,7 @@
"max_tokens": 65536,
"mode": "image_generation",
"output_cost_per_image": 0.134,
"output_cost_per_image_token": 1.2e-04,
"output_cost_per_token": 1.2e-05,
"output_cost_per_token_batches": 6e-06,
"source": "https://docs.cloud.google.com/vertex-ai/generative-ai/docs/models/gemini/3-pro-image"

View file

@ -1272,19 +1272,19 @@ class MCPServerManager:
prefix = get_server_prefix(server)
for tool in tools:
prefixed_name = add_server_prefix_to_name(tool.name, prefix)
tool_copy = tool.model_copy(deep=True)
name_to_use = prefixed_name if add_prefix else tool.name
original_name = tool_copy.name
prefixed_name = add_server_prefix_to_name(original_name, prefix)
tool_obj = MCPTool(
name=name_to_use,
description=tool.description,
inputSchema=tool.inputSchema,
)
prefixed_tools.append(tool_obj)
name_to_use = prefixed_name if add_prefix else original_name
# Preserve all tool fields including metadata/_meta by avoiding mutation
tool_copy.name = name_to_use
prefixed_tools.append(tool_copy)
# Update tool to server mapping for resolution (support both forms)
self.tool_name_to_mcp_server_name_mapping[tool.name] = prefix
self.tool_name_to_mcp_server_name_mapping[original_name] = prefix
self.tool_name_to_mcp_server_name_mapping[prefixed_name] = prefix
verbose_logger.info(
@ -1953,9 +1953,9 @@ class MCPServerManager:
server_name_from_prefix
):
return server
elif normalize_server_name(server.server_name) == normalize_server_name(
server_name_from_prefix
):
elif normalize_server_name(
server.server_name
) == normalize_server_name(server_name_from_prefix):
return server
return None

View file

@ -6,7 +6,7 @@ LiteLLM MCP Server Routes
import asyncio
import contextlib
from datetime import datetime
from typing import Any, AsyncIterator, Dict, List, Optional, Tuple, Union
from typing import Any, AsyncIterator, Dict, List, Optional, Tuple, Union, cast
from fastapi import FastAPI, HTTPException
from pydantic import AnyUrl, ConfigDict
@ -72,7 +72,13 @@ if MCP_AVAILABLE:
auth_context_var,
)
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from mcp.types import EmbeddedResource, ImageContent, Prompt, TextContent
from mcp.types import (
CallToolResult,
EmbeddedResource,
ImageContent,
Prompt,
TextContent,
)
from mcp.types import Tool as MCPTool
from litellm.proxy._experimental.mcp_server.auth.litellm_auth_handler import (
@ -234,7 +240,7 @@ if MCP_AVAILABLE:
@server.call_tool()
async def mcp_server_tool_call(
name: str, arguments: Dict[str, Any] | None
) -> List[Union[TextContent, ImageContent, EmbeddedResource]]:
) -> CallToolResult:
"""
Call a specific tool with the provided arguments
@ -300,26 +306,37 @@ if MCP_AVAILABLE:
)
except BlockedPiiEntityError as e:
verbose_logger.error(f"BlockedPiiEntityError in MCP tool call: {str(e)}")
# Return error as text content for MCP protocol
return [
TextContent(
text=f"Error: Blocked PII entity detected - {str(e)}", type="text"
)
]
return CallToolResult(
content=[
TextContent(
text=f"Error: Blocked PII entity detected - {str(e)}",
type="text",
)
],
isError=True,
)
except GuardrailRaisedException as e:
verbose_logger.error(f"GuardrailRaisedException in MCP tool call: {str(e)}")
# Return error as text content for MCP protocol
return [
TextContent(text=f"Error: Guardrail violation - {str(e)}", type="text")
]
return CallToolResult(
content=[
TextContent(
text=f"Error: Guardrail violation - {str(e)}", type="text"
)
],
isError=True,
)
except HTTPException as e:
verbose_logger.error(f"HTTPException in MCP tool call: {str(e)}")
# Return error as text content for MCP protocol
return [TextContent(text=f"Error: {str(e.detail)}", type="text")]
return CallToolResult(
content=[TextContent(text=f"Error: {str(e.detail)}", type="text")],
isError=True,
)
except Exception as e:
verbose_logger.exception(f"MCP mcp_server_tool_call - error: {e}")
# Return error as text content for MCP protocol
return [TextContent(text=f"Error: {str(e)}", type="text")]
return CallToolResult(
content=[TextContent(text=f"Error: {str(e)}", type="text")],
isError=True,
)
return response
@ -1173,7 +1190,7 @@ if MCP_AVAILABLE:
oauth2_headers: Optional[Dict[str, str]] = None,
raw_headers: Optional[Dict[str, str]] = None,
**kwargs: Any,
) -> List[Union[TextContent, ImageContent, EmbeddedResource]]:
) -> CallToolResult:
"""
Call a specific tool with the provided arguments (handles prefixed tool names)
"""
@ -1237,9 +1254,9 @@ if MCP_AVAILABLE:
"litellm_logging_obj", None
)
if litellm_logging_obj:
litellm_logging_obj.model_call_details["mcp_tool_call_metadata"] = (
standard_logging_mcp_tool_call
)
litellm_logging_obj.model_call_details[
"mcp_tool_call_metadata"
] = standard_logging_mcp_tool_call
litellm_logging_obj.model = f"MCP: {name}"
# Check if tool exists in local registry first (for OpenAPI-based tools)
# These tools are registered with their prefixed names
@ -1247,7 +1264,8 @@ if MCP_AVAILABLE:
local_tool = global_mcp_tool_registry.get_tool(name)
if local_tool:
verbose_logger.debug(f"Executing local registry tool: {name}")
response = await _handle_local_mcp_tool(name, arguments)
local_content = await _handle_local_mcp_tool(name, arguments)
response = CallToolResult(content=cast(Any, local_content), isError=False)
# Try managed MCP server tool (pass the full prefixed name)
# Primary and recommended way to use external MCP servers
@ -1279,7 +1297,12 @@ if MCP_AVAILABLE:
# Deprecated: Local MCP Server Tool
#########################################################
else:
response = await _handle_local_mcp_tool(original_tool_name, arguments)
local_content = await _handle_local_mcp_tool(
original_tool_name, arguments
)
response = CallToolResult(
content=cast(Any, local_content), isError=False
)
#########################################################
# Post MCP Tool Call Hook
@ -1432,7 +1455,7 @@ if MCP_AVAILABLE:
oauth2_headers: Optional[Dict[str, str]] = None,
raw_headers: Optional[Dict[str, str]] = None,
litellm_logging_obj: Optional[Any] = None,
) -> List[Union[TextContent, ImageContent, EmbeddedResource]]:
) -> CallToolResult:
"""Handle tool execution for managed server tools"""
# Import here to avoid circular import
from litellm.proxy.proxy_server import proxy_logging_obj
@ -1449,7 +1472,7 @@ if MCP_AVAILABLE:
proxy_logging_obj=proxy_logging_obj,
)
verbose_logger.debug("CALL TOOL RESULT: %s", call_tool_result)
return call_tool_result.content # type: ignore[return-value]
return call_tool_result
async def _handle_local_mcp_tool(
name: str, arguments: Dict[str, Any]
@ -1741,14 +1764,16 @@ if MCP_AVAILABLE:
)
auth_context_var.set(auth_user)
def get_auth_context() -> Tuple[
Optional[UserAPIKeyAuth],
Optional[str],
Optional[List[str]],
Optional[Dict[str, Dict[str, str]]],
Optional[Dict[str, str]],
Optional[Dict[str, str]],
]:
def get_auth_context() -> (
Tuple[
Optional[UserAPIKeyAuth],
Optional[str],
Optional[List[str]],
Optional[Dict[str, Dict[str, str]]],
Optional[Dict[str, str]],
Optional[Dict[str, str]],
]
):
"""
Get the UserAPIKeyAuth from the auth context variable.

View file

@ -3639,6 +3639,8 @@ class DailyOrganizationSpendTransaction(BaseDailySpendTransaction):
class DailyUserSpendTransaction(BaseDailySpendTransaction):
user_id: str
class DailyEndUserSpendTransaction(BaseDailySpendTransaction):
end_user_id: str
class DailyTagSpendTransaction(BaseDailySpendTransaction):
request_id: Optional[str]

View file

@ -9,9 +9,9 @@ import os
import secrets
from typing import Literal, Optional, cast
import litellm
from fastapi import HTTPException
import litellm
from litellm.constants import LITELLM_PROXY_ADMIN_NAME
from litellm.proxy._types import (
LiteLLM_UserTable,
@ -64,13 +64,19 @@ def get_ui_credentials(master_key: Optional[str]) -> tuple[str, str]:
class LoginResult:
"""Result object containing authentication data from login."""
user_id: str
key: str
user_email: Optional[str]
user_role: str
login_method: Literal["sso", "username_password"]
def __init__(
self,
user_id: str,
key: str,
user_email: Optional[str],
user_role: str,
login_method: str = "username_password",
login_method: Literal["sso", "username_password"] = "username_password",
):
self.user_id = user_id
self.key = key
@ -193,14 +199,14 @@ async def authenticate_user(
key = response["token"] # type: ignore
if get_secret_bool("EXPERIMENTAL_UI_LOGIN"):
from litellm.proxy.auth.auth_checks import ExperimentalUIJWTToken
user_info: Optional[LiteLLM_UserTable] = None
if _user_row is not None:
user_info = _user_row
elif (
user_id is not None
): # if user_id is not None, we are using the UI_USERNAME and UI_PASSWORD
from litellm.proxy.auth.auth_checks import ExperimentalUIJWTToken
user_info = LiteLLM_UserTable(
user_id=user_id,
user_role=user_role,

View file

@ -25,6 +25,7 @@ from litellm.proxy._types import (
DailyTagSpendTransaction,
DailyOrganizationSpendTransaction,
DailyTeamSpendTransaction,
DailyEndUserSpendTransaction,
DailyUserSpendTransaction,
DBSpendUpdateTransactions,
Litellm_EntityType,
@ -65,6 +66,7 @@ class DBSpendUpdateWriter:
self.spend_update_queue = SpendUpdateQueue()
self.daily_spend_update_queue = DailySpendUpdateQueue()
self.daily_team_spend_update_queue = DailySpendUpdateQueue()
self.daily_end_user_spend_update_queue = DailySpendUpdateQueue()
self.daily_org_spend_update_queue = DailySpendUpdateQueue()
self.daily_tag_spend_update_queue = DailySpendUpdateQueue()
@ -182,6 +184,13 @@ class DBSpendUpdateWriter:
)
)
asyncio.create_task(
self.add_spend_log_transaction_to_daily_end_user_transaction(
payload=payload,
prisma_client=prisma_client,
)
)
asyncio.create_task(
self.add_spend_log_transaction_to_daily_team_transaction(
payload=payload,
@ -475,6 +484,7 @@ class DBSpendUpdateWriter:
daily_spend_update_queue=self.daily_spend_update_queue,
daily_team_spend_update_queue=self.daily_team_spend_update_queue,
daily_org_spend_update_queue=self.daily_org_spend_update_queue,
daily_end_user_spend_update_queue=self.daily_end_user_spend_update_queue,
daily_tag_spend_update_queue=self.daily_tag_spend_update_queue,
)
@ -538,6 +548,16 @@ class DBSpendUpdateWriter:
proxy_logging_obj=proxy_logging_obj,
daily_spend_transactions=daily_tag_spend_update_transactions,
)
daily_end_user_spend_update_transactions = (
await self.redis_update_buffer.get_all_daily_end_user_spend_update_transactions_from_redis_buffer()
)
if daily_end_user_spend_update_transactions is not None:
await DBSpendUpdateWriter.update_daily_end_user_spend(
n_retry_times=n_retry_times,
prisma_client=prisma_client,
proxy_logging_obj=proxy_logging_obj,
daily_spend_transactions=daily_end_user_spend_update_transactions,
)
except Exception as e:
verbose_proxy_logger.error(f"Error committing spend updates: {e}")
finally:
@ -627,6 +647,20 @@ class DBSpendUpdateWriter:
daily_spend_transactions=daily_tag_spend_update_transactions,
)
################## Daily End-User Spend Update Transactions ##################
# Aggregate all in memory daily end-user spend transactions and commit to db
daily_end_user_spend_update_transactions = cast(
Dict[str, DailyEndUserSpendTransaction],
await self.daily_end_user_spend_update_queue.flush_and_get_aggregated_daily_spend_update_transactions(),
)
await DBSpendUpdateWriter.update_daily_end_user_spend(
n_retry_times=n_retry_times,
prisma_client=prisma_client,
proxy_logging_obj=proxy_logging_obj,
daily_spend_transactions=daily_end_user_spend_update_transactions,
)
async def _commit_spend_updates_to_db( # noqa: PLR0915
self,
prisma_client: PrismaClient,
@ -990,6 +1024,20 @@ class DBSpendUpdateWriter:
) -> None:
...
@overload
@staticmethod
async def _update_daily_spend(
n_retry_times: int,
prisma_client: PrismaClient,
proxy_logging_obj: ProxyLogging,
daily_spend_transactions: Dict[str, DailyEndUserSpendTransaction],
entity_type: Literal["end_user"],
entity_id_field: str,
table_name: str,
unique_constraint_name: str,
) -> None:
...
@overload
@staticmethod
async def _update_daily_spend(
@ -1015,14 +1063,15 @@ class DBSpendUpdateWriter:
Dict[str, DailyTeamSpendTransaction],
Dict[str, DailyTagSpendTransaction],
Dict[str, DailyOrganizationSpendTransaction],
Dict[str, DailyEndUserSpendTransaction],
],
entity_type: Literal["user", "team", "org", "tag"],
entity_type: Literal["user", "team", "org", "tag", "end_user"],
entity_id_field: str,
table_name: str,
unique_constraint_name: str,
) -> None:
"""
Generic function to update daily spend for any entity type (user, team, org, tag)
Generic function to update daily spend for any entity type (user, team, org, tag, end_user)
"""
from litellm.proxy.utils import _raise_failed_update_spend_exception
@ -1267,6 +1316,27 @@ class DBSpendUpdateWriter:
unique_constraint_name="organization_id_date_api_key_model_custom_llm_provider_mcp_namespaced_tool_name",
)
@staticmethod
async def update_daily_end_user_spend(
n_retry_times: int,
prisma_client: PrismaClient,
proxy_logging_obj: ProxyLogging,
daily_spend_transactions: Dict[str, DailyEndUserSpendTransaction],
):
"""
Batch job to update LiteLLM_DailyEndUserSpend table using in-memory daily_spend_transactions
"""
await DBSpendUpdateWriter._update_daily_spend(
n_retry_times=n_retry_times,
prisma_client=prisma_client,
proxy_logging_obj=proxy_logging_obj,
daily_spend_transactions=daily_spend_transactions,
entity_type="end_user",
entity_id_field="end_user_id",
table_name="litellm_dailyenduserspend",
unique_constraint_name="end_user_id_date_api_key_model_custom_llm_provider_mcp_namespaced_tool_name",
)
@staticmethod
async def update_daily_tag_spend(
n_retry_times: int,
@ -1292,7 +1362,7 @@ class DBSpendUpdateWriter:
self,
payload: Union[dict, SpendLogsPayload],
prisma_client: PrismaClient,
type: Literal["user", "team", "org", "request_tags"] = "user",
type: Literal["user", "team", "org", "request_tags", "end_user"] = "user",
) -> Optional[BaseDailySpendTransaction]:
common_expected_keys = ["startTime", "api_key"]
if type == "user":
@ -1303,6 +1373,8 @@ class DBSpendUpdateWriter:
expected_keys = ["organization_id", *common_expected_keys]
elif type == "request_tags":
expected_keys = ["request_tags", *common_expected_keys]
elif type == "end_user":
expected_keys = ["end_user_id", *common_expected_keys]
else:
raise ValueError(f"Invalid type: {type}")
if not all(key in payload for key in expected_keys):
@ -1474,6 +1546,48 @@ class DBSpendUpdateWriter:
update={daily_transaction_key: daily_transaction}
)
async def add_spend_log_transaction_to_daily_end_user_transaction(
self,
payload: SpendLogsPayload,
prisma_client: Optional[PrismaClient] = None,
) -> None:
if prisma_client is None:
verbose_proxy_logger.debug(
"prisma_client is None. Skipping writing spend logs to db."
)
return
end_user_id = payload.get("end_user")
if end_user_id is None or end_user_id == "":
verbose_proxy_logger.debug(
"end_user is None or empty for request. Skipping incrementing end user spend."
)
return
payload_with_end_user_id = cast(
SpendLogsPayload,
{
**payload,
"end_user_id": end_user_id,
},
)
base_daily_transaction = (
await self._common_add_spend_log_transaction_to_daily_transaction(
payload_with_end_user_id, prisma_client, "end_user"
)
)
if base_daily_transaction is None:
return
daily_transaction_key = f"{end_user_id}_{base_daily_transaction['date']}_{payload_with_end_user_id['api_key']}_{payload_with_end_user_id['model']}_{payload_with_end_user_id['custom_llm_provider']}"
daily_transaction = DailyEndUserSpendTransaction(
end_user_id=end_user_id, **base_daily_transaction
)
await self.daily_end_user_spend_update_queue.add_update(
update={daily_transaction_key: daily_transaction}
)
async def add_spend_log_transaction_to_daily_tag_transaction(
self,
payload: SpendLogsPayload,

View file

@ -16,6 +16,7 @@ from litellm.constants import (
REDIS_DAILY_TAG_SPEND_UPDATE_BUFFER_KEY,
REDIS_DAILY_TEAM_SPEND_UPDATE_BUFFER_KEY,
REDIS_DAILY_ORG_SPEND_UPDATE_BUFFER_KEY,
REDIS_DAILY_END_USER_SPEND_UPDATE_BUFFER_KEY,
REDIS_UPDATE_BUFFER_KEY,
)
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
@ -24,6 +25,7 @@ from litellm.proxy._types import (
DailyTeamSpendTransaction,
DailyUserSpendTransaction,
DailyOrganizationSpendTransaction,
DailyEndUserSpendTransaction,
DBSpendUpdateTransactions,
)
from litellm.proxy.db.db_transaction_queue.base_update_queue import service_logger_obj
@ -107,6 +109,7 @@ class RedisUpdateBuffer:
daily_spend_update_queue: DailySpendUpdateQueue,
daily_team_spend_update_queue: DailySpendUpdateQueue,
daily_org_spend_update_queue: DailySpendUpdateQueue,
daily_end_user_spend_update_queue: DailySpendUpdateQueue,
daily_tag_spend_update_queue: DailySpendUpdateQueue,
):
"""
@ -172,6 +175,9 @@ class RedisUpdateBuffer:
daily_org_spend_update_transactions = (
await daily_org_spend_update_queue.flush_and_get_aggregated_daily_spend_update_transactions()
)
daily_end_user_spend_update_transactions = (
await daily_end_user_spend_update_queue.flush_and_get_aggregated_daily_spend_update_transactions()
)
daily_tag_spend_update_transactions = (
await daily_tag_spend_update_queue.flush_and_get_aggregated_daily_spend_update_transactions()
)
@ -207,6 +213,12 @@ class RedisUpdateBuffer:
service_type=ServiceTypes.REDIS_DAILY_SPEND_UPDATE_QUEUE,
)
await self._store_transactions_in_redis(
transactions=daily_end_user_spend_update_transactions,
redis_key=REDIS_DAILY_END_USER_SPEND_UPDATE_BUFFER_KEY,
service_type=ServiceTypes.REDIS_DAILY_END_USER_SPEND_UPDATE_QUEUE,
)
await self._store_transactions_in_redis(
transactions=daily_tag_spend_update_transactions,
redis_key=REDIS_DAILY_TAG_SPEND_UPDATE_BUFFER_KEY,
@ -365,6 +377,30 @@ class RedisUpdateBuffer:
),
)
async def get_all_daily_end_user_spend_update_transactions_from_redis_buffer(
self,
) -> Optional[Dict[str, DailyEndUserSpendTransaction]]:
"""
Gets all the daily end-user spend update transactions from Redis
"""
if self.redis_cache is None:
return None
list_of_transactions = await self.redis_cache.async_lpop(
key=REDIS_DAILY_END_USER_SPEND_UPDATE_BUFFER_KEY,
count=MAX_REDIS_BUFFER_DEQUEUE_COUNT,
)
if list_of_transactions is None:
return None
list_of_daily_spend_update_transactions = [
json.loads(transaction) for transaction in list_of_transactions
]
return cast(
Dict[str, DailyEndUserSpendTransaction],
DailySpendUpdateQueue.get_aggregated_daily_spend_update_transactions(
list_of_daily_spend_update_transactions
),
)
async def get_all_daily_tag_spend_update_transactions_from_redis_buffer(
self,
) -> Optional[Dict[str, DailyTagSpendTransaction]]:

View file

@ -127,7 +127,7 @@ class GenericGuardrailAPI(CustomGuardrail):
for field_name in GenericGuardrailAPIMetadata.__annotations__.keys():
value = metadata_dict.get(field_name)
if value is not None:
result_metadata[field_name] = value
result_metadata[field_name] = value # type: ignore[literal-required]
# handle user_api_key_token = user_api_key_hash
if metadata_dict.get("user_api_key_token") is not None:

View file

@ -207,6 +207,14 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail):
Send text to the Presidio analyzer endpoint and get analysis results
"""
try:
# Skip empty or whitespace-only text to avoid Presidio errors
# Common in tool/function calling where assistant content is empty
if not text or len(text.strip()) == 0:
verbose_proxy_logger.debug(
"Skipping Presidio analysis for empty/whitespace-only text"
)
return []
async with aiohttp.ClientSession() as session:
if self.mock_redacted_text is not None:
return self.mock_redacted_text
@ -231,9 +239,42 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail):
async with session.post(analyze_url, json=analyze_payload) as response:
analyze_results = await response.json()
verbose_proxy_logger.debug("analyze_results: %s", analyze_results)
# Handle error responses from Presidio (e.g., {'error': 'No text provided'})
# Presidio may return a dict instead of a list when errors occur
if isinstance(analyze_results, dict):
if "error" in analyze_results:
verbose_proxy_logger.warning(
"Presidio analyzer returned error: %s, returning empty list",
analyze_results.get("error")
)
return []
# If it's a dict but not an error, try to process it as a single item
verbose_proxy_logger.debug(
"Presidio returned dict (not list), attempting to process as single item"
)
try:
return [PresidioAnalyzeResponseItem(**analyze_results)]
except Exception as e:
verbose_proxy_logger.warning(
"Failed to parse Presidio dict response: %s, returning empty list",
e
)
return []
# Normal case: list of results
final_results = []
for item in analyze_results:
final_results.append(PresidioAnalyzeResponseItem(**item))
try:
final_results.append(PresidioAnalyzeResponseItem(**item))
except TypeError as te:
# Handle case where item is not a dict (shouldn't happen, but be defensive)
verbose_proxy_logger.warning(
"Skipping invalid Presidio result item: %s (error: %s)",
item,
te
)
continue
return final_results
except Exception as e:
raise e

View file

@ -409,7 +409,7 @@ def _build_model_param_to_info_mapping(model_list: list) -> dict:
Returns:
Dictionary mapping model parameter to list of model info dicts
"""
model_param_to_info = {}
model_param_to_info: dict = {}
for model in model_list:
model_info = model.get("model_info", {})
model_name = model.get("model_name")
@ -1048,28 +1048,6 @@ async def health_readiness():
index_info = "index does not exist - error: " + str(e)
cache_type = {"type": cache_type, "index_info": index_info}
# build license metadata
try:
from litellm.proxy.proxy_server import _license_check # type: ignore
license_available: bool = _license_check.is_premium() if _license_check else False
license_expiration: Optional[str] = None
if getattr(_license_check, "airgapped_license_data", None):
license_expiration = _license_check.airgapped_license_data.get( # type: ignore[arg-type]
"expiration_date"
)
license_metadata = {
"license": {
"has_license": license_available,
"expiration_date": license_expiration,
}
}
except Exception:
# fail closed: don't let license check break readiness
license_metadata = {"license": {"has_license": False, "expiration_date": None}}
# check DB
if prisma_client is not None: # if db passed in, check if it's connected
db_health_status = await _db_health_readiness_check()
@ -1080,7 +1058,6 @@ async def health_readiness():
"litellm_version": version,
"success_callbacks": success_callback_names,
"use_aiohttp_transport": AsyncHTTPHandler._should_use_aiohttp_transport(),
**license_metadata,
**db_health_status,
}
else:
@ -1091,7 +1068,6 @@ async def health_readiness():
"litellm_version": version,
"success_callbacks": success_callback_names,
"use_aiohttp_transport": AsyncHTTPHandler._should_use_aiohttp_transport(),
**license_metadata,
}
except Exception as e:
raise HTTPException(status_code=503, detail=f"Service Unhealthy ({str(e)})")

View file

@ -80,6 +80,32 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
weight = convert_priority_to_percent(value, model_info)
return weight
def _get_priority_from_user_api_key_dict(
self, user_api_key_dict: UserAPIKeyAuth
) -> Optional[str]:
"""
Get priority from user_api_key_dict.
Checks team metadata first (takes precedence), then falls back to key metadata.
Args:
user_api_key_dict: User authentication info
Returns:
Priority string if found, None otherwise
"""
priority: Optional[str] = None
# Check team metadata first (takes precedence)
if user_api_key_dict.team_metadata is not None:
priority = user_api_key_dict.team_metadata.get("priority", None)
# Fall back to key metadata
if priority is None:
priority = user_api_key_dict.metadata.get("priority", None)
return priority
def _normalize_priority_weights(
self, model_info: ModelGroupInfo
) -> Dict[str, float]:
@ -328,7 +354,7 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
model: str,
model_group_info: ModelGroupInfo,
user_api_key_dict: UserAPIKeyAuth,
key_priority: Optional[str],
priority: Optional[str],
saturation: float,
data: dict,
) -> None:
@ -355,7 +381,7 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
model: Model name
model_group_info: Model configuration
user_api_key_dict: User authentication info
key_priority: User's priority level
priority: User's priority level
saturation: Current saturation level
data: Request data dictionary
@ -384,7 +410,7 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
priority_descriptors = self._create_priority_based_descriptors(
model=model,
user_api_key_dict=user_api_key_dict,
priority=key_priority,
priority=priority,
)
if priority_descriptors:
descriptors_to_check.extend(priority_descriptors)
@ -412,14 +438,14 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
status_code=429,
detail={
"error": f"Model capacity reached for {model}. "
f"Priority: {key_priority}, "
f"Priority: {priority}, "
f"Rate limit type: {status['rate_limit_type']}, "
f"Remaining: {status['limit_remaining']}"
},
headers={
"retry-after": str(self.v3_limiter.window_size),
"rate_limit_type": str(status["rate_limit_type"]),
"x-litellm-priority": key_priority or "default",
"x-litellm-priority": priority or "default",
},
)
@ -427,13 +453,13 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
elif descriptor_key == "priority_model" and should_enforce_priority:
verbose_proxy_logger.debug(
f"Enforcing priority limits for {model}, saturation: {saturation:.1%}, "
f"priority: {key_priority}"
f"priority: {priority}"
)
raise HTTPException(
status_code=429,
detail={
"error": f"Priority-based rate limit exceeded. "
f"Priority: {key_priority}, "
f"Priority: {priority}, "
f"Rate limit type: {status['rate_limit_type']}, "
f"Remaining: {status['limit_remaining']}, "
f"Model saturation: {saturation:.1%}"
@ -441,7 +467,7 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
headers={
"retry-after": str(self.v3_limiter.window_size),
"rate_limit_type": str(status["rate_limit_type"]),
"x-litellm-priority": key_priority or "default",
"x-litellm-priority": priority or "default",
"x-litellm-saturation": f"{saturation:.2%}",
},
)
@ -521,7 +547,9 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
return None
model = data["model"]
key_priority: Optional[str] = user_api_key_dict.metadata.get("priority", None)
priority = self._get_priority_from_user_api_key_dict(
user_api_key_dict=user_api_key_dict
)
# Get model configuration
model_group_info: Optional[ModelGroupInfo] = (
@ -543,7 +571,7 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
verbose_proxy_logger.debug(
f"[Dynamic Rate Limiter] Model={model}, Saturation={saturation:.1%}, "
f"Threshold={saturation_threshold:.1%}, Priority={key_priority}"
f"Threshold={saturation_threshold:.1%}, Priority={priority}"
)
# STEP 2: Check rate limits in THREE phases
@ -555,7 +583,7 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
model=model,
model_group_info=model_group_info,
user_api_key_dict=user_api_key_dict,
key_priority=key_priority,
priority=priority,
saturation=saturation,
data=data,
)
@ -586,8 +614,8 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
# Add additional priority-specific headers
if isinstance(response, ModelResponse):
key_priority: Optional[str] = user_api_key_dict.metadata.get(
"priority", None
priority = self._get_priority_from_user_api_key_dict(
user_api_key_dict=user_api_key_dict
)
# Get existing additional headers
@ -599,7 +627,7 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
)
# Add priority information
additional_headers["x-litellm-priority"] = key_priority or "default"
additional_headers["x-litellm-priority"] = priority or "default"
additional_headers["x-litellm-rate-limiter-version"] = "v3"
# Update response
@ -614,3 +642,121 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
f"Error in dynamic rate limiter v3 post-call hook: {str(e)}"
)
return response
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
"""
Update token usage for priority-based rate limiting after successful API calls.
Increments token counters for:
- model_saturation_check: Model-wide token tracking
- priority_model: Priority-specific token tracking
"""
from litellm.litellm_core_utils.core_helpers import (
_get_parent_otel_span_from_kwargs,
)
from litellm.proxy.common_utils.callback_utils import (
get_model_group_from_litellm_kwargs,
)
from litellm.types.caching import RedisPipelineIncrementOperation
from litellm.types.utils import Usage
try:
verbose_proxy_logger.debug(
"INSIDE dynamic rate limiter ASYNC SUCCESS LOGGING"
)
litellm_parent_otel_span = _get_parent_otel_span_from_kwargs(kwargs)
# Get metadata from standard_logging_object
standard_logging_object = kwargs.get("standard_logging_object") or {}
standard_logging_metadata = standard_logging_object.get("metadata") or {}
# Get model and priority
model_group = get_model_group_from_litellm_kwargs(kwargs)
if not model_group:
return
# Get priority from user_api_key_auth_metadata in standard_logging_metadata
# This is where user_api_key_dict.metadata is stored during pre-call
user_api_key_auth_metadata = standard_logging_metadata.get("user_api_key_auth_metadata") or {}
key_priority: Optional[str] = user_api_key_auth_metadata.get("priority")
# Get total tokens from response
total_tokens = 0
rate_limit_type = self.v3_limiter.get_rate_limit_type()
if isinstance(response_obj, ModelResponse):
_usage = getattr(response_obj, "usage", None)
if _usage and isinstance(_usage, Usage):
if rate_limit_type == "output":
total_tokens = _usage.completion_tokens
elif rate_limit_type == "input":
total_tokens = _usage.prompt_tokens
elif rate_limit_type == "total":
total_tokens = _usage.total_tokens
if total_tokens == 0:
return
# Create pipeline operations for token increments
pipeline_operations: List[RedisPipelineIncrementOperation] = []
# Model-wide token tracking (model_saturation_check)
model_token_key = self.v3_limiter.create_rate_limit_keys(
key="model_saturation_check",
value=model_group,
rate_limit_type="tokens",
)
pipeline_operations.append(
RedisPipelineIncrementOperation(
key=model_token_key,
increment_value=total_tokens,
ttl=self.v3_limiter.window_size,
)
)
# Priority-specific token tracking (priority_model)
# Determine priority key (same logic as _get_priority_allocation)
has_explicit_priority = (
key_priority is not None
and litellm.priority_reservation is not None
and key_priority in litellm.priority_reservation
)
if has_explicit_priority and key_priority is not None:
priority_key = f"{model_group}:{key_priority}"
else:
priority_key = f"{model_group}:default_pool"
priority_token_key = self.v3_limiter.create_rate_limit_keys(
key="priority_model",
value=priority_key,
rate_limit_type="tokens",
)
pipeline_operations.append(
RedisPipelineIncrementOperation(
key=priority_token_key,
increment_value=total_tokens,
ttl=self.v3_limiter.window_size,
)
)
# Execute token increments with TTL preservation
if pipeline_operations:
await self.v3_limiter.async_increment_tokens_with_ttl_preservation(
pipeline_operations=pipeline_operations,
parent_otel_span=litellm_parent_otel_span,
)
# Only log 'priority' if it's known safe; otherwise, redact.
SAFE_PRIORITIES = {"low", "medium", "high", "default"}
logged_priority = key_priority if key_priority in SAFE_PRIORITIES else "REDACTED"
verbose_proxy_logger.debug(
f"[Dynamic Rate Limiter] Incremented tokens by {total_tokens} for "
f"model={model_group}, priority={logged_priority}"
)
except Exception as e:
verbose_proxy_logger.exception(
f"Error in dynamic rate limiter success event: {str(e)}"
)

View file

@ -7,7 +7,6 @@ import litellm
from litellm._logging import verbose_proxy_logger
from litellm._uuid import uuid
from litellm.proxy._types import (
CommonProxyErrors,
GenerateKeyRequest,
GenerateKeyResponse,
KeyRequest,
@ -45,9 +44,13 @@ class KeyManagementEventHooks:
)
from litellm.proxy.proxy_server import litellm_proxy_admin_name
await KeyManagementEventHooks._send_key_created_email(
response.model_dump(exclude_none=True)
)
# Send email notification - non-blocking, independent operation
try:
await KeyManagementEventHooks._send_key_created_email(
response.model_dump(exclude_none=True)
)
except Exception as e:
verbose_proxy_logger.warning(f"Failed to send key created email: {e}")
# Enterprise Feature - Audit Logging. Enable with litellm.store_audit_logs = True
if litellm.store_audit_logs is True:
@ -69,11 +72,17 @@ class KeyManagementEventHooks:
)
)
)
# store the generated key in the secret manager
await KeyManagementEventHooks._store_virtual_key_in_secret_manager(
secret_name=data.key_alias or f"virtual-key-{response.token_id}",
secret_token=response.key,
)
# Store the generated key in the secret manager - non-blocking, independent operation
try:
await KeyManagementEventHooks._store_virtual_key_in_secret_manager(
secret_name=data.key_alias or f"virtual-key-{response.token_id}",
secret_token=response.key,
)
except Exception as e:
verbose_proxy_logger.warning(
f"Failed to store virtual key in secret manager: {e}"
)
@staticmethod
async def async_key_updated_hook(
@ -132,22 +141,31 @@ class KeyManagementEventHooks:
)
from litellm.proxy.proxy_server import litellm_proxy_admin_name
# store the generated key in the secret manager
# Store the generated key in the secret manager - non-blocking, independent operation
if data is not None and response.token_id is not None:
initial_secret_name = (
existing_key_row.key_alias or f"virtual-key-{existing_key_row.token}"
)
await KeyManagementEventHooks._rotate_virtual_key_in_secret_manager(
current_secret_name=initial_secret_name,
new_secret_name=data.key_alias or f"virtual-key-{response.token_id}",
new_secret_value=response.key,
)
try:
initial_secret_name = (
existing_key_row.key_alias
or f"virtual-key-{existing_key_row.token}"
)
await KeyManagementEventHooks._rotate_virtual_key_in_secret_manager(
current_secret_name=initial_secret_name,
new_secret_name=data.key_alias or f"virtual-key-{response.token_id}",
new_secret_value=response.key,
)
except Exception as e:
verbose_proxy_logger.warning(
f"Failed to rotate virtual key in secret manager: {e}"
)
# send key rotated email if configured
await KeyManagementEventHooks._send_key_rotated_email(
response=response.model_dump(exclude_none=True),
existing_key_alias=existing_key_row.key_alias,
)
# Send key rotated email if configured - non-blocking, independent operation
try:
await KeyManagementEventHooks._send_key_rotated_email(
response=response.model_dump(exclude_none=True),
existing_key_alias=existing_key_row.key_alias,
)
except Exception as e:
verbose_proxy_logger.warning(f"Failed to send key rotated email: {e}")
# store the audit log
if litellm.store_audit_logs is True and existing_key_row.token is not None:
@ -324,66 +342,109 @@ class KeyManagementEventHooks:
)
@staticmethod
async def _send_key_created_email(response: dict):
def _is_email_sending_enabled() -> bool:
"""
Check if email sending is enabled via v2 enterprise loggers or v0 alerting config.
Returns True only if email is actually configured, preventing any email
processing when the user has not opted in.
"""
# Check v2 enterprise email loggers
try:
from litellm_enterprise.enterprise_callbacks.send_emails.base_email import (
BaseEmailLogger,
)
except ImportError:
raise Exception(
"Trying to use Email Hooks"
+ CommonProxyErrors.missing_enterprise_package.value
initialized_email_loggers = (
litellm.logging_callback_manager.get_custom_loggers_for_type(
callback_type=BaseEmailLogger
)
)
if len(initialized_email_loggers) > 0:
return True
except ImportError:
pass
# Check v0 alerting config
from litellm.proxy.proxy_server import general_settings
if "email" in general_settings.get("alerting", []):
return True
return False
@staticmethod
async def _send_key_created_email(response: dict):
"""
Send key created email if email sending is enabled.
This method is non-blocking - it will return silently if email is not
configured, and will log warnings instead of raising exceptions on failure.
"""
# Early exit if email is not enabled
if not KeyManagementEventHooks._is_email_sending_enabled():
verbose_proxy_logger.debug(
"Email sending not enabled, skipping key created email"
)
return
from litellm.proxy.proxy_server import general_settings, proxy_logging_obj
##########################
# v2 integration for emails (enterprise)
##########################
try:
from litellm_enterprise.enterprise_callbacks.send_emails.base_email import (
BaseEmailLogger,
)
from litellm_enterprise.types.enterprise_callbacks.send_emails import (
SendKeyCreatedEmailEvent,
)
initialized_email_loggers = (
litellm.logging_callback_manager.get_custom_loggers_for_type(
callback_type=BaseEmailLogger
)
)
if len(initialized_email_loggers) > 0:
event = SendKeyCreatedEmailEvent(
virtual_key=response.get("key", ""),
event="key_created",
event_group=Litellm_EntityType.KEY,
event_message="API Key Created",
token=response.get("token", ""),
spend=response.get("spend", 0.0),
max_budget=response.get("max_budget", 0.0),
user_id=response.get("user_id", None),
team_id=response.get("team_id", "Default Team"),
key_alias=response.get("key_alias", None),
)
for email_logger in initialized_email_loggers:
if isinstance(email_logger, BaseEmailLogger):
await email_logger.send_key_created_email(
send_key_created_email_event=event,
)
return
except ImportError:
raise Exception(
"Trying to use Email Hooks"
+ CommonProxyErrors.missing_enterprise_package.value
)
event = SendKeyCreatedEmailEvent(
virtual_key=response.get("key", ""),
event="key_created",
event_group=Litellm_EntityType.KEY,
event_message="API Key Created",
token=response.get("token", ""),
spend=response.get("spend", 0.0),
max_budget=response.get("max_budget", 0.0),
user_id=response.get("user_id", None),
team_id=response.get("team_id", "Default Team"),
key_alias=response.get("key_alias", None),
)
##########################
# v2 integration for emails
##########################
initialized_email_loggers = (
litellm.logging_callback_manager.get_custom_loggers_for_type(
callback_type=BaseEmailLogger
)
)
if len(initialized_email_loggers) > 0:
for email_logger in initialized_email_loggers:
if isinstance(email_logger, BaseEmailLogger):
await email_logger.send_key_created_email(
send_key_created_email_event=event,
)
pass
##########################
# v0 integration for emails
##########################
else:
if "email" not in general_settings.get("alerting", []):
raise ValueError(
"Email alerting not setup on config.yaml. Please set `alerting=['email']. \nDocs: https://docs.litellm.ai/docs/proxy/email`"
)
if "email" in general_settings.get("alerting", []):
from litellm.proxy._types import WebhookEvent
event = WebhookEvent(
event="key_created",
event_group=Litellm_EntityType.KEY,
event_message="API Key Created",
token=response.get("token", ""),
spend=response.get("spend", 0.0),
max_budget=response.get("max_budget", 0.0),
user_id=response.get("user_id", None),
team_id=response.get("team_id", "Default Team"),
key_alias=response.get("key_alias", None),
)
# If user configured email alerting - send an Email letting their end-user know the key was created
asyncio.create_task(
proxy_logging_obj.slack_alerting_instance.send_key_created_or_user_invited_email(
@ -393,25 +454,39 @@ class KeyManagementEventHooks:
@staticmethod
async def _send_key_rotated_email(response: dict, existing_key_alias: Optional[str]):
"""
Send key rotated email if email sending is enabled.
This method is non-blocking - it will return silently if email is not
configured, and will log warnings instead of raising exceptions on failure.
"""
# Early exit if email is not enabled
if not KeyManagementEventHooks._is_email_sending_enabled():
verbose_proxy_logger.debug(
"Email sending not enabled, skipping key rotated email"
)
return
try:
from litellm_enterprise.enterprise_callbacks.send_emails.base_email import (
BaseEmailLogger,
)
except ImportError:
raise Exception(
"Trying to use Email Hooks"
+ CommonProxyErrors.missing_enterprise_package.value
# Enterprise package not installed - v0 doesn't support key rotated email
verbose_proxy_logger.debug(
"Enterprise package not installed, skipping key rotated email"
)
return
try:
from litellm_enterprise.types.enterprise_callbacks.send_emails import (
SendKeyRotatedEmailEvent,
)
except ImportError:
raise Exception(
"Trying to use Email Hooks"
+ CommonProxyErrors.missing_enterprise_package.value
verbose_proxy_logger.debug(
"Enterprise types not available, skipping key rotated email"
)
return
event = SendKeyRotatedEmailEvent(
virtual_key=response.get("key", ""),

View file

@ -65,6 +65,12 @@ for i = 1, #KEYS, 2 do
table.insert(results, increment_value) -- counter
else
local counter = redis.call('INCR', counter_key)
-- This happens when window_key exists but counter_key doesn't (e.g., tokens key
-- created after requests key when both share the same window_key)
local current_ttl = redis.call('TTL', counter_key)
if current_ttl == -1 then
redis.call('EXPIRE', counter_key, window_size)
end
table.insert(results, window_start) -- window_start
table.insert(results, counter) -- counter
end

View file

@ -20,6 +20,10 @@ from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import *
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.utils import handle_exception_on_proxy
from litellm.types.proxy.management_endpoints.common_daily_activity import (
SpendAnalyticsPaginatedResponse,
)
from litellm.proxy.management_endpoints.common_daily_activity import get_daily_activity
router = APIRouter()
@ -673,4 +677,78 @@ async def list_end_user(
str(e)
)
)
raise handle_exception_on_proxy(e)
raise handle_exception_on_proxy(e)
@router.get(
"/customer/daily/activity",
tags=["Customer Management"],
dependencies=[Depends(user_api_key_auth)],
response_model=SpendAnalyticsPaginatedResponse,
)
@router.get(
"/end_user/daily/activity",
tags=["Customer Management"],
include_in_schema=False,
dependencies=[Depends(user_api_key_auth)],
)
async def get_customer_daily_activity(
end_user_ids: Optional[str] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
model: Optional[str] = None,
api_key: Optional[str] = None,
page: int = 1,
page_size: int = 10,
exclude_end_user_ids: Optional[str] = None,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
Get daily activity for specific organizations or all accessible organizations.
"""
from litellm.proxy.proxy_server import (
prisma_client,
)
if prisma_client is None:
raise HTTPException(
status_code=500,
detail={"error": CommonProxyErrors.db_not_connected_error.value},
)
# Parse comma-separated ids
end_user_ids_list = end_user_ids.split(",") if end_user_ids else None
exclude_end_user_ids_list: Optional[List[str]] = None
if exclude_end_user_ids:
exclude_end_user_ids_list = (
exclude_end_user_ids.split(",") if exclude_end_user_ids else None
)
# Fetch organization aliases for metadata
where_condition = {}
if end_user_ids_list:
where_condition["user_id"] = {"in": list(end_user_ids_list)}
end_user_aliases = await prisma_client.db.litellm_endusertable.find_many(
where=where_condition
)
end_user_alias_metadata = {
e.user_id: {"alias": e.alias}
for e in end_user_aliases
}
# Query daily activity for organizations
return await get_daily_activity(
prisma_client=prisma_client,
table_name="litellm_dailyenduserspend",
entity_id_field="end_user_id",
entity_id=end_user_ids_list,
entity_metadata_field=end_user_alias_metadata,
exclude_entity_ids=exclude_end_user_ids_list,
start_date=start_date,
end_date=end_date,
model=model,
api_key=api_key,
page=page,
page_size=page_size,
)

View file

@ -31,6 +31,7 @@ from litellm.proxy._types import (
LiteLLM_ManagementEndpoint_MetadataFields_Premium,
LiteLLM_ModelTable,
LiteLLM_OrganizationTable,
LiteLLM_OrganizationTableWithMembers,
LiteLLM_TeamMembership,
LiteLLM_TeamTable,
LiteLLM_TeamTableCachedObj,
@ -1051,7 +1052,7 @@ async def fetch_and_validate_organization(
organization_row = await prisma_client.db.litellm_organizationtable.find_unique(
where={"organization_id": organization_id},
include={"litellm_budget_table": True, "users": True},
include={"litellm_budget_table": True, "members": True},
)
if organization_row is None:
@ -1064,7 +1065,7 @@ async def fetch_and_validate_organization(
validate_team_org_change(
team=LiteLLM_TeamTable(**existing_team_row.model_dump()),
organization=LiteLLM_OrganizationTable(**organization_row.model_dump()),
organization=LiteLLM_OrganizationTableWithMembers(**organization_row.model_dump()),
llm_router=llm_router,
)
@ -1072,7 +1073,7 @@ async def fetch_and_validate_organization(
def validate_team_org_change(
team: LiteLLM_TeamTable, organization: LiteLLM_OrganizationTable, llm_router: Router
team: LiteLLM_TeamTable, organization: LiteLLM_OrganizationTableWithMembers, llm_router: Router
) -> bool:
"""
Validate that a team can be moved to an organization.
@ -1123,7 +1124,7 @@ def validate_team_org_change(
# Check if the team's user_id is a member of the org
team_members = [m.user_id for m in team.members_with_roles]
org_members = [m.user_id for m in organization.users] if organization.users else []
org_members = [m.user_id for m in organization.members] if organization.members else []
not_in_org = [
m
for m in team_members

View file

@ -971,18 +971,23 @@ def _update_metadata_with_tags_in_header(request: Request, metadata: dict) -> di
Used for google and vertex JS SDKs, and Azure passthrough
Checks both 'tags' and 'x-litellm-tags' headers
"""
# Initialize tags list if it doesn't exist
if "tags" not in metadata:
metadata["tags"] = []
tags_to_add = []
# Check for 'tags' header first
_tags = request.headers.get("tags")
if _tags:
metadata["tags"].extend([tag.strip() for tag in _tags.split(",")])
tags_to_add.extend([tag.strip() for tag in _tags.split(",")])
_tags = request.headers.get("x-litellm-tags")
if _tags:
metadata["tags"].extend([tag.strip() for tag in _tags.split(",")])
tags_to_add.extend([tag.strip() for tag in _tags.split(",")])
# Only add tags key if there are tags to add
if tags_to_add:
if "tags" not in metadata:
metadata["tags"] = []
metadata["tags"].extend(tags_to_add)
return metadata

View file

@ -1,67 +1,16 @@
model_list:
- model_name: qwen-25vl-72b
- model_name: openai/gpt-4o-mini
litellm_params:
model: bedrock/openai/arn:aws:bedrock:us-east-1:046319184608:imported-model/0m2lasirsp6z
model: openai/gpt-4o-mini
tpm: 1000
guardrails:
- guardrail_name: "bedrock-pre-guard"
litellm_params:
guardrail: bedrock
mode: "pre_call"
guardrailIdentifier: ff6ujrregl1q
guardrailVersion: "DRAFT"
# like MCPs/vector stores
search_tools:
- search_tool_name: litellm-search
litellm_params:
search_provider: perplexity
api_key: os.environ/PERPLEXITYAI_API_KEY
- search_tool_name: firecrawl-search
litellm_params:
search_provider: firecrawl
api_key: os.environ/FIRECRAWL_API_KEY
litellm_settings:
max_end_user_budget_id: "2f6634cd-c631-4d3b-96c7-ad510ea06eaf"
# Comprehensive logging settings
store_audit_logs: true
verbose: true
log_level: "DEBUG" # Options: DEBUG, INFO, WARNING, ERROR
callbacks: ["s3_v2", "smtp_email"]
s3_callback_params:
s3_endpoint_url: "https://localhost:443" # Replace with your Minio server URL and port
s3_aws_access_key_id: "minioadmin"
s3_aws_secret_access_key: "minioadmin"
s3_region_name: "minio" # This can be any value for Minio
s3_bucket_name: "litellm-test" # Replace with your bucket name
s3_use_ssl: False
s3_verify: False
cache: True
cache_params:
type: local
drop_params: True
callbacks: ["dynamic_rate_limiter_v3"]
priority_reservation:
"prod": 0.9 # 90% reserved for production
"dev": 0.1 # 10% reserved for development
general_settings:
store_prompts_in_spend_logs: True
pass_through_endpoints:
- path: "/special/rerank"
target: "https://api.cohere.com/v1/rerank"
headers:
Authorization: "Bearer os.environ/COHERE_API_KEY"
guardrails:
bedrock-pre-guard:
request_fields: ["documents[*].text"]
vector_store_registry:
- vector_store_name: "bedrock-litellm-website-knowledgebase"
litellm_params:
vector_store_id: "T37J8R4WTM"
custom_llm_provider: "bedrock"
vector_store_description: "Bedrock vector store for the Litellm website knowledgebase"
vector_store_metadata:
source: "https://www.litellm.com/docs"

View file

@ -45,7 +45,10 @@ from litellm.constants import (
LITELLM_SETTINGS_SAFE_DB_OVERRIDES,
)
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.proxy.common_utils.callback_utils import normalize_callback_names
from litellm.proxy.common_utils.callback_utils import (
normalize_callback_names,
process_callback,
)
from litellm.proxy.common_utils.realtime_utils import _realtime_request_body
from litellm.types.utils import (
ModelResponse,
@ -54,7 +57,6 @@ from litellm.types.utils import (
TokenCountResponse,
)
from litellm.utils import load_credentials_from_list
from litellm.proxy.common_utils.callback_utils import process_callback
if TYPE_CHECKING:
from aiohttp import ClientSession
@ -168,8 +170,8 @@ from litellm.constants import (
PROXY_BUDGET_RESCHEDULER_MIN_TIME,
)
from litellm.exceptions import RejectedRequestError
from litellm.integrations.SlackAlerting.slack_alerting import SlackAlerting
from litellm.integrations.custom_logger import CustomLogger
from litellm.integrations.SlackAlerting.slack_alerting import SlackAlerting
from litellm.litellm_core_utils.core_helpers import (
_get_parent_otel_span_from_kwargs,
get_litellm_metadata_from_kwargs,
@ -613,7 +615,7 @@ async def proxy_shutdown_event():
await jwt_handler.close()
if db_writer_client is not None:
await db_writer_client.close()
await db_writer_client.close() # type: ignore[reportGeneralTypeIssues]
# flush remaining langfuse logs
if "langfuse" in litellm.success_callback:
@ -792,7 +794,7 @@ async def proxy_startup_event(app: FastAPI):
except Exception as e:
verbose_proxy_logger.error(f"Error closing shared aiohttp session: {e}")
await proxy_shutdown_event()
await proxy_shutdown_event() # type: ignore[reportGeneralTypeIssues]
app = FastAPI(
@ -802,7 +804,7 @@ app = FastAPI(
description=_description,
version=version,
root_path=server_root_path, # check if user passed root path, FastAPI defaults this value to ""
lifespan=proxy_startup_event,
lifespan=proxy_startup_event, # type: ignore[reportGeneralTypeIssues]
)
vertex_live_passthrough_vertex_base = VertexBase()
@ -8330,9 +8332,9 @@ async def login(request: Request): # noqa: PLR0915
# Generate JWT token
import jwt
jwt_token = jwt.encode( # type: ignore
jwt_token = jwt.encode(
cast(dict, returned_ui_token_object),
master_key,
cast(str, master_key),
algorithm="HS256",
)
@ -8377,9 +8379,9 @@ async def login_v2(request: Request): # noqa: PLR0915
import jwt
jwt_token = jwt.encode( # type: ignore
jwt_token = jwt.encode(
cast(dict, returned_ui_token_object),
master_key,
cast(str, master_key),
algorithm="HS256",
)

View file

@ -2124,17 +2124,67 @@
"litellm_provider": "oci",
"credential_fields": [
{
"key": "api_key",
"label": "API Key",
"placeholder": null,
"tooltip": null,
"key": "oci_user",
"label": "OCI User OCID",
"placeholder": "ocid1.user.oc1..aaaaaaaaexample",
"tooltip": "The OCID of the user making the API call",
"required": true,
"field_type": "password",
"field_type": "text",
"options": null,
"default_value": null
},
{
"key": "oci_fingerprint",
"label": "OCI Key Fingerprint",
"placeholder": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
"tooltip": "The fingerprint of the API signing key",
"required": true,
"field_type": "text",
"options": null,
"default_value": null
},
{
"key": "oci_tenancy",
"label": "OCI Tenancy OCID",
"placeholder": "ocid1.tenancy.oc1..aaaaaaaaexample",
"tooltip": "The OCID of your tenancy",
"required": true,
"field_type": "text",
"options": null,
"default_value": null
},
{
"key": "oci_region",
"label": "OCI Region",
"placeholder": "us-ashburn-1",
"tooltip": "The OCI region identifier (e.g., us-ashburn-1, eu-frankfurt-1)",
"required": true,
"field_type": "text",
"options": null,
"default_value": null
},
{
"key": "oci_compartment_id",
"label": "OCI Compartment OCID",
"placeholder": "ocid1.compartment.oc1..aaaaaaaaexample",
"tooltip": "The OCID of the compartment containing the GenAI resources",
"required": true,
"field_type": "text",
"options": null,
"default_value": null
},
{
"key": "oci_key",
"label": "OCI Private Key (PEM)",
"placeholder": "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----",
"tooltip": "The full PEM-encoded private key content for API signing. Paste the entire key including BEGIN/END markers.",
"required": true,
"field_type": "textarea",
"options": null,
"default_value": null
}
],
"default_model_placeholder": "oci/xai.grok-4"
"default_model_placeholder": "oci/xai.grok-3"
},
{
"provider": "OVHCLOUD",

View file

@ -465,6 +465,34 @@ model LiteLLM_DailyOrganizationSpend {
@@index([mcp_namespaced_tool_name])
}
// Track daily end user (customer) spend metrics per model and key
model LiteLLM_DailyEndUserSpend {
id String @id @default(uuid())
end_user_id String?
date String
api_key String
model String?
model_group String?
custom_llm_provider String?
mcp_namespaced_tool_name String?
prompt_tokens BigInt @default(0)
completion_tokens BigInt @default(0)
cache_read_input_tokens BigInt @default(0)
cache_creation_input_tokens BigInt @default(0)
spend Float @default(0.0)
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
failed_requests BigInt @default(0)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@unique([end_user_id, date, api_key, model, custom_llm_provider, mcp_namespaced_tool_name])
@@index([date])
@@index([end_user_id])
@@index([api_key])
@@index([model])
@@index([mcp_namespaced_tool_name])
}
// Track daily team spend metrics per model and key
model LiteLLM_DailyTeamSpend {
id String @id @default(uuid())

View file

@ -39,6 +39,7 @@ from litellm.types.responses.main import (
GenericResponseOutputItem,
GenericResponseOutputItemContentAnnotation,
OutputFunctionToolCall,
OutputImageGenerationCall,
OutputText,
)
from litellm.types.utils import (
@ -830,9 +831,9 @@ class LiteLLMCompletionResponsesConfig:
def _transform_chat_completion_choices_to_responses_output(
chat_completion_response: ModelResponse,
choices: List[Choices],
) -> List[Union[GenericResponseOutputItem, OutputFunctionToolCall]]:
) -> List[Union[GenericResponseOutputItem, OutputFunctionToolCall, OutputImageGenerationCall]]:
responses_output: List[
Union[GenericResponseOutputItem, OutputFunctionToolCall]
Union[GenericResponseOutputItem, OutputFunctionToolCall, OutputImageGenerationCall]
] = []
responses_output.extend(
@ -881,28 +882,130 @@ class LiteLLMCompletionResponsesConfig:
]
return []
@staticmethod
def _extract_image_generation_output_items(
chat_completion_response: ModelResponse,
choice: Choices,
) -> List[OutputImageGenerationCall]:
"""
Extract image generation outputs from a choice that contains images.
Transforms message.images from chat completion format:
{
'image_url': {'url': 'data:image/png;base64,iVBORw0...'},
'type': 'image_url',
'index': 0
}
To Responses API format:
{
'type': 'image_generation_call',
'id': 'img_...',
'status': 'completed',
'result': 'iVBORw0...' # Pure base64 without data: prefix
}
"""
image_generation_items: List[OutputImageGenerationCall] = []
images = getattr(choice.message, 'images', [])
if not images:
return image_generation_items
for idx, image_item in enumerate(images):
# Extract base64 from data URL
image_url = image_item.get('image_url', {}).get('url', '')
base64_data = LiteLLMCompletionResponsesConfig._extract_base64_from_data_url(image_url)
if base64_data:
image_generation_items.append(
OutputImageGenerationCall(
type="image_generation_call",
id=f"{chat_completion_response.id}_img_{idx}",
status=LiteLLMCompletionResponsesConfig._map_finish_reason_to_image_generation_status(
choice.finish_reason
),
result=base64_data,
)
)
return image_generation_items
@staticmethod
def _map_finish_reason_to_image_generation_status(
finish_reason: Optional[str],
) -> Literal["in_progress", "completed", "incomplete", "failed"]:
"""
Map finish_reason to image generation status.
Image generation status only supports: in_progress, completed, incomplete, failed
(does not support: cancelled, queued like general ResponsesAPIStatus)
"""
if finish_reason == "stop":
return "completed"
elif finish_reason == "length":
return "incomplete"
elif finish_reason in ["content_filter", "error"]:
return "failed"
else:
# Default to completed for other cases
return "completed"
@staticmethod
def _extract_base64_from_data_url(data_url: str) -> Optional[str]:
"""
Extract pure base64 string from a data URL.
Input: 'data:image/png;base64,iVBORw0KGgoAAAANS...'
Output: 'iVBORw0KGgoAAAANS...'
If input is already pure base64 (no prefix), return as-is.
"""
if not data_url:
return None
# Check if it's a data URL with prefix
if data_url.startswith('data:'):
# Split by comma to separate prefix from base64 data
parts = data_url.split(',', 1)
if len(parts) == 2:
return parts[1] # Return the base64 part
return None
else:
# Already pure base64
return data_url
@staticmethod
def _extract_message_output_items(
chat_completion_response: ModelResponse,
choices: List[Choices],
) -> List[GenericResponseOutputItem]:
message_output_items = []
) -> List[Union[GenericResponseOutputItem, OutputImageGenerationCall]]:
message_output_items: List[Union[GenericResponseOutputItem, OutputImageGenerationCall]] = []
for choice in choices:
message_output_items.append(
GenericResponseOutputItem(
type="message",
id=chat_completion_response.id,
status=LiteLLMCompletionResponsesConfig._map_chat_completion_finish_reason_to_responses_status(
choice.finish_reason
),
role=choice.message.role,
content=[
LiteLLMCompletionResponsesConfig._transform_chat_message_to_response_output_text(
choice.message
)
],
# Check if message has images (image generation)
if hasattr(choice.message, 'images') and choice.message.images:
# Extract image generation output
image_generation_items = LiteLLMCompletionResponsesConfig._extract_image_generation_output_items(
chat_completion_response=chat_completion_response,
choice=choice,
)
message_output_items.extend(image_generation_items)
else:
# Regular message output
message_output_items.append(
GenericResponseOutputItem(
type="message",
id=chat_completion_response.id,
status=LiteLLMCompletionResponsesConfig._map_chat_completion_finish_reason_to_responses_status(
choice.finish_reason
),
role=choice.message.role,
content=[
LiteLLMCompletionResponsesConfig._transform_chat_message_to_response_output_text(
choice.message
)
],
)
)
)
return message_output_items
@staticmethod

View file

@ -76,6 +76,7 @@ from litellm.types.llms.base import BaseLiteLLMOpenAIResponseObject
from litellm.types.responses.main import (
GenericResponseOutputItem,
OutputFunctionToolCall,
OutputImageGenerationCall,
)
FileContent = Union[IO[bytes], bytes, PathLike]
@ -1071,7 +1072,7 @@ class ResponsesAPIResponse(BaseLiteLLMOpenAIResponseObject):
object: Optional[str] = None
output: Union[
List[Union[ResponseOutputItem, Dict]],
List[Union[GenericResponseOutputItem, OutputFunctionToolCall]],
List[Union[GenericResponseOutputItem, OutputFunctionToolCall, OutputImageGenerationCall]],
]
parallel_tool_calls: Optional[bool] = None
temperature: Optional[float] = None

View file

@ -16,7 +16,7 @@ class ProviderCredentialField(BaseModel):
placeholder: Optional[str] = None
tooltip: Optional[str] = None
required: bool = False
field_type: Literal["text", "password", "select", "upload"] = "text"
field_type: Literal["text", "password", "select", "upload", "textarea"] = "text"
options: Optional[List[str]] = None
default_value: Optional[str] = None

View file

@ -36,6 +36,15 @@ class OutputFunctionToolCall(BaseLiteLLMOpenAIResponseObject):
status: Literal["in_progress", "completed", "incomplete"]
class OutputImageGenerationCall(BaseLiteLLMOpenAIResponseObject):
"""An image generation call output"""
type: Literal["image_generation_call"]
id: str
status: Literal["in_progress", "completed", "incomplete", "failed"]
result: Optional[str] # Base64 encoded image data (without data:image prefix)
class GenericResponseOutputItem(BaseLiteLLMOpenAIResponseObject):
"""
Generic response API output item

View file

@ -1,10 +1,11 @@
import enum
from litellm._uuid import uuid
from typing import List, Optional
from pydantic import BaseModel, Field
from typing_extensions import TypedDict
from litellm._uuid import uuid
class ServiceMetrics(enum.Enum):
COUNTER = "counter"
@ -33,6 +34,8 @@ class ServiceTypes(str, enum.Enum):
# daily spend update queue - actual transaction events
IN_MEMORY_DAILY_SPEND_UPDATE_QUEUE = "in_memory_daily_spend_update_queue"
REDIS_DAILY_SPEND_UPDATE_QUEUE = "redis_daily_spend_update_queue"
REDIS_DAILY_END_USER_SPEND_UPDATE_QUEUE = "redis_daily_end_user_spend_update_queue"
REDIS_DAILY_ORG_SPEND_UPDATE_QUEUE = "redis_daily_org_spend_update_queue"
REDIS_DAILY_TEAM_SPEND_UPDATE_QUEUE = "redis_daily_team_spend_update_queue"
REDIS_DAILY_TAG_SPEND_UPDATE_QUEUE = "redis_daily_tag_spend_update_queue"
# spend update queue - current spend of key, user, team
@ -87,6 +90,9 @@ DEFAULT_SERVICE_CONFIGS = {
ServiceTypes.REDIS_DAILY_SPEND_UPDATE_QUEUE.value: {
"metrics": [ServiceMetrics.GAUGE]
},
ServiceTypes.REDIS_DAILY_END_USER_SPEND_UPDATE_QUEUE.value: {
"metrics": [ServiceMetrics.GAUGE]
},
ServiceTypes.IN_MEMORY_SPEND_UPDATE_QUEUE.value: {
"metrics": [ServiceMetrics.GAUGE]
},

View file

@ -7023,11 +7023,13 @@ class ProviderConfigManager:
"""
# Check JSON providers FIRST
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
from litellm.llms.openai_like.dynamic_config import create_config_class
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
if JSONProviderRegistry.exists(provider.value):
provider_config = JSONProviderRegistry.get(provider.value)
if provider_config is None:
raise ValueError(f"Provider {provider.value} not found")
return create_config_class(provider_config)()
if (
@ -7050,6 +7052,8 @@ class ProviderConfigManager:
return litellm.DatabricksConfig()
elif litellm.LlmProviders.XAI == provider:
return litellm.XAIChatConfig()
elif litellm.LlmProviders.ZAI == provider:
return litellm.ZAIChatConfig()
elif litellm.LlmProviders.LAMBDA_AI == provider:
return litellm.LambdaAIChatConfig()
elif litellm.LlmProviders.LLAMA == provider:

View file

@ -5164,6 +5164,19 @@
"supports_function_calling": true,
"supports_tool_choice": true
},
"azure_ai/mistral-large-3": {
"input_cost_per_token": 5e-07,
"litellm_provider": "azure_ai",
"max_input_tokens": 256000,
"max_output_tokens": 8191,
"max_tokens": 8191,
"mode": "chat",
"output_cost_per_token": 1.5e-06,
"source": "https://azure.microsoft.com/en-us/blog/introducing-mistral-large-3-in-microsoft-foundry-open-capable-and-ready-for-production-workloads/",
"supports_function_calling": true,
"supports_tool_choice": true,
"supports_vision": true
},
"azure_ai/mistral-medium-2505": {
"input_cost_per_token": 4e-07,
"litellm_provider": "azure_ai",
@ -12134,6 +12147,7 @@
"max_tokens": 65536,
"mode": "image_generation",
"output_cost_per_image": 0.134,
"output_cost_per_image_token": 1.2e-04,
"output_cost_per_token": 1.2e-05,
"output_cost_per_token_batches": 6e-06,
"source": "https://ai.google.dev/gemini-api/docs/pricing",
@ -13871,6 +13885,7 @@
"max_tokens": 65536,
"mode": "image_generation",
"output_cost_per_image": 0.134,
"output_cost_per_image_token": 1.2e-04,
"output_cost_per_token": 1.2e-05,
"rpm": 1000,
"tpm": 4000000,
@ -18745,6 +18760,21 @@
"supports_response_schema": true,
"supports_tool_choice": true
},
"mistral/mistral-large-3": {
"input_cost_per_token": 5e-07,
"litellm_provider": "mistral",
"max_input_tokens": 256000,
"max_output_tokens": 8191,
"max_tokens": 8191,
"mode": "chat",
"output_cost_per_token": 1.5e-06,
"source": "https://docs.mistral.ai/models/mistral-large-3-25-12",
"supports_assistant_prefill": true,
"supports_function_calling": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true
},
"mistral/mistral-medium": {
"input_cost_per_token": 2.7e-06,
"litellm_provider": "mistral",
@ -25774,6 +25804,7 @@
"max_tokens": 65536,
"mode": "image_generation",
"output_cost_per_image": 0.134,
"output_cost_per_image_token": 1.2e-04,
"output_cost_per_token": 1.2e-05,
"output_cost_per_token_batches": 6e-06,
"source": "https://docs.cloud.google.com/vertex-ai/generative-ai/docs/models/gemini/3-pro-image"

View file

@ -59,7 +59,7 @@ websockets = {version = "^15.0.1", optional = true}
boto3 = {version = "1.36.0", optional = true}
redisvl = {version = "^0.4.1", optional = true, markers = "python_version >= '3.9' and python_version < '3.14'"}
mcp = {version = "^1.21.2", optional = true, python = ">=3.10"}
litellm-proxy-extras = {version = "0.4.9", optional = true}
litellm-proxy-extras = {version = "0.4.10", optional = true}
rich = {version = "13.7.1", optional = true}
litellm-enterprise = {version = "0.1.23", optional = true}
diskcache = {version = "^5.6.1", optional = true}

View file

@ -44,7 +44,7 @@ sentry_sdk==2.21.0 # for sentry error handling
detect-secrets==1.5.0 # Enterprise - secret detection / masking in LLM requests
cryptography==44.0.1
tzdata==2025.1 # IANA time zone database
litellm-proxy-extras==0.4.9 # for proxy extras - e.g. prisma migrations
litellm-proxy-extras==0.4.10 # for proxy extras - e.g. prisma migrations
### LITELLM PACKAGE DEPENDENCIES
python-dotenv==1.0.1 # for env
tiktoken==0.8.0 # for calculating usage

View file

@ -465,6 +465,34 @@ model LiteLLM_DailyOrganizationSpend {
@@index([mcp_namespaced_tool_name])
}
// Track daily end user (customer) spend metrics per model and key
model LiteLLM_DailyEndUserSpend {
id String @id @default(uuid())
end_user_id String?
date String
api_key String
model String?
model_group String?
custom_llm_provider String?
mcp_namespaced_tool_name String?
prompt_tokens BigInt @default(0)
completion_tokens BigInt @default(0)
cache_read_input_tokens BigInt @default(0)
cache_creation_input_tokens BigInt @default(0)
spend Float @default(0.0)
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
failed_requests BigInt @default(0)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@unique([end_user_id, date, api_key, model, custom_llm_provider, mcp_namespaced_tool_name])
@@index([date])
@@index([end_user_id])
@@index([api_key])
@@index([model])
@@index([mcp_namespaced_tool_name])
}
// Track daily team spend metrics per model and key
model LiteLLM_DailyTeamSpend {
id String @id @default(uuid())

View file

@ -25,7 +25,10 @@ async def test_health_and_chat_completion():
readiness_response = await response.json()
assert readiness_response["status"] == "connected"
<<<<<<< HEAD
=======
>>>>>>> parent of d89990e0c5 (Add license metadata to health/readiness endpoint. (#15997))
# Test liveness endpoint
async with session.get("http://0.0.0.0:4000/health/liveness") as response:
assert response.status == 200

View file

@ -0,0 +1,199 @@
"""
Tests for OCI Chat Transformation module.
These tests verify the OCI credential handling, particularly the PEM key
normalization logic for handling different newline formats.
"""
import os
import sys
import pytest
sys.path.insert(
0, os.path.abspath("../../../../..")
) # Adds the parent directory to the system path
from litellm.llms.oci.chat.transformation import OCIChatConfig
from litellm.llms.oci.common_utils import OCIError
@pytest.fixture
def config():
return OCIChatConfig()
class TestOCIKeyNormalization:
"""Tests for OCI private key content normalization."""
def test_oci_key_with_escaped_newlines(self, config):
"""Test that escaped newlines (\\n) are converted to actual newlines."""
# Simulate PEM content with escaped newlines (as would come from JSON/UI input)
escaped_pem = "-----BEGIN RSA PRIVATE KEY-----\\nMIIEowIBAAKCAQEA...\\n-----END RSA PRIVATE KEY-----"
optional_params = {
"oci_user": "ocid1.user.oc1..test",
"oci_fingerprint": "aa:bb:cc:dd",
"oci_tenancy": "ocid1.tenancy.oc1..test",
"oci_region": "us-ashburn-1",
"oci_key": escaped_pem,
}
# We can't fully test signing without a real key, but we can verify
# the error message indicates the key was processed (not a type error)
with pytest.raises(Exception) as exc_info:
config._sign_with_manual_credentials(
headers={},
optional_params=optional_params,
request_data={"test": "data"},
api_base="https://test.oci.oraclecloud.com/api",
)
# The error should be about key format/loading, not about type
# This confirms the string was processed and newlines were normalized
error_message = str(exc_info.value)
assert "must be a string" not in error_message.lower()
def test_oci_key_with_crlf_newlines(self, config):
"""Test that Windows-style CRLF newlines are normalized to LF."""
# Simulate PEM content with CRLF newlines
crlf_pem = "-----BEGIN RSA PRIVATE KEY-----\r\nMIIEowIBAAKCAQEA...\r\n-----END RSA PRIVATE KEY-----"
optional_params = {
"oci_user": "ocid1.user.oc1..test",
"oci_fingerprint": "aa:bb:cc:dd",
"oci_tenancy": "ocid1.tenancy.oc1..test",
"oci_region": "us-ashburn-1",
"oci_key": crlf_pem,
}
with pytest.raises(Exception) as exc_info:
config._sign_with_manual_credentials(
headers={},
optional_params=optional_params,
request_data={"test": "data"},
api_base="https://test.oci.oraclecloud.com/api",
)
error_message = str(exc_info.value)
assert "must be a string" not in error_message.lower()
def test_oci_key_rejects_non_string_type(self, config):
"""Test that non-string oci_key values raise OCIError."""
optional_params = {
"oci_user": "ocid1.user.oc1..test",
"oci_fingerprint": "aa:bb:cc:dd",
"oci_tenancy": "ocid1.tenancy.oc1..test",
"oci_region": "us-ashburn-1",
"oci_key": {"invalid": "dict"}, # Wrong type
}
with pytest.raises(OCIError) as exc_info:
config._sign_with_manual_credentials(
headers={},
optional_params=optional_params,
request_data={"test": "data"},
api_base="https://test.oci.oraclecloud.com/api",
)
assert exc_info.value.status_code == 400
assert "must be a string" in str(exc_info.value.message)
assert "dict" in str(exc_info.value.message)
def test_oci_key_rejects_list_type(self, config):
"""Test that list oci_key values raise OCIError."""
optional_params = {
"oci_user": "ocid1.user.oc1..test",
"oci_fingerprint": "aa:bb:cc:dd",
"oci_tenancy": "ocid1.tenancy.oc1..test",
"oci_region": "us-ashburn-1",
"oci_key": ["invalid", "list"], # Wrong type
}
with pytest.raises(OCIError) as exc_info:
config._sign_with_manual_credentials(
headers={},
optional_params=optional_params,
request_data={"test": "data"},
api_base="https://test.oci.oraclecloud.com/api",
)
assert exc_info.value.status_code == 400
assert "must be a string" in str(exc_info.value.message)
assert "list" in str(exc_info.value.message)
class TestOCIValidateEnvironment:
"""Tests for OCI environment validation."""
def test_missing_required_credentials_raises_error(self, config):
"""Test that missing required credentials raise an error."""
with pytest.raises(Exception) as exc_info:
config.validate_environment(
headers={},
model="oci/xai.grok-3",
messages=[{"role": "user", "content": "Hello"}],
optional_params={}, # No credentials provided
litellm_params={},
api_key=None,
api_base=None,
)
error_message = str(exc_info.value)
assert "oci_user" in error_message
assert "oci_fingerprint" in error_message
assert "oci_tenancy" in error_message
def test_validate_environment_with_all_credentials(self, config):
"""Test that validation passes with all required credentials."""
headers = config.validate_environment(
headers={},
model="oci/xai.grok-3",
messages=[{"role": "user", "content": "Hello"}],
optional_params={
"oci_user": "ocid1.user.oc1..test",
"oci_fingerprint": "aa:bb:cc:dd",
"oci_tenancy": "ocid1.tenancy.oc1..test",
"oci_region": "us-ashburn-1",
"oci_compartment_id": "ocid1.compartment.oc1..test",
"oci_key": "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----",
},
litellm_params={},
api_key=None,
api_base=None,
)
assert headers["content-type"] == "application/json"
assert "user-agent" in headers
class TestOCIGetCompleteUrl:
"""Tests for OCI URL generation."""
def test_get_complete_url_default_region(self, config):
"""Test URL generation with default region."""
url = config.get_complete_url(
api_base=None,
api_key=None,
model="oci/xai.grok-3",
optional_params={},
litellm_params={},
stream=False,
)
assert "us-ashburn-1" in url
assert "inference.generativeai" in url
assert "/20231130/actions/chat" in url
def test_get_complete_url_custom_region(self, config):
"""Test URL generation with custom region."""
url = config.get_complete_url(
api_base=None,
api_key=None,
model="oci/xai.grok-3",
optional_params={"oci_region": "eu-frankfurt-1"},
litellm_params={},
stream=False,
)
assert "eu-frankfurt-1" in url
assert "inference.generativeai" in url

View file

@ -1065,7 +1065,7 @@ def test_parse_content_for_reasoning(content, expected_reasoning, expected_conte
("gemini/gemini-1.5-pro", True),
("predibase/llama3-8b-instruct", True),
("gpt-3.5-turbo", False),
("groq/llama-3.3-70b-versatile", True),
("groq/llama-3.3-70b-versatile", False),
],
)
def test_supports_response_schema(model, expected_bool):

View file

@ -109,7 +109,11 @@ async def test_mcp_cost_tracking():
# Add assertions
assert response is not None
response_list = list(response) # Convert iterable to list
# Handle CallToolResult - access .content for the list of content items
if isinstance(response, CallToolResult):
response_list = response.content
else:
response_list = list(response) # Convert iterable to list for backward compatibility
assert len(response_list) == 1
assert isinstance(response_list[0], TextContent)
assert response_list[0].text == "Test response"
@ -238,8 +242,8 @@ async def test_mcp_cost_tracking_per_tool():
assert response1 is not None
assert response2 is not None
response_list_1 = list(response1)
response_list_2 = list(response2)
response_list_1 = list(response1.content)
response_list_2 = list(response2.content)
assert len(response_list_1) == 1
assert len(response_list_2) == 1

View file

@ -39,9 +39,8 @@ test("view internal user page", async ({ page }) => {
const rowCount = await page.locator("tbody tr").count();
expect(rowCount).toBeGreaterThan(0);
const userIdHeader = page.locator("th", { hasText: "User ID" });
page.screenshot({ path: "test-results/user_id_header.png" });
await expect(userIdHeader).toBeVisible();
const userIdHeader = await page.locator("th", { hasText: "User ID" });
await expect(userIdHeader).toBeVisible({ timeout: 10000 });
// test pagination
// Wait for pagination controls to be visible

View file

@ -1389,7 +1389,9 @@ async def test_custom_validate_called():
jwt_handler = MagicMock()
jwt_handler.litellm_jwtauth = MagicMock(
custom_validate=mock_custom_validate, allowed_routes=["/chat/completions"]
custom_validate=mock_custom_validate,
allowed_routes=["/chat/completions"],
oidc_userinfo_enabled=False,
)
jwt_handler.auth_jwt = AsyncMock(return_value={"sub": "test_user"})

View file

@ -438,9 +438,9 @@ def test_string_cost_values_edge_cases():
# Expected costs:
# Prompt: 1000 * 1e-6 + 100 * 0 (invalid string becomes 0)
# Completion: 500 * 2e-6 (text_tokens == completion_tokens, so is_text_tokens_total=True, no separate audio cost)
# Completion: 500 * 2e-6 + 50 * 2e-6 (audio tokens fall back to base cost when output_cost_per_audio_token is None)
expected_prompt_cost = 1000 * 1e-6
expected_completion_cost = 500 * 2e-6
expected_completion_cost = 500 * 2e-6 + 50 * 2e-6
assert round(prompt_cost, 12) == round(expected_prompt_cost, 12)
assert round(completion_cost, 12) == round(expected_completion_cost, 12)
@ -720,6 +720,72 @@ def test_service_tier_fallback_pricing():
assert abs(std_cost[1] - expected_standard_completion) < 1e-10, f"Standard completion cost mismatch: {std_cost[1]} vs {expected_standard_completion}"
def test_gemini_image_generation_cost_with_zero_text_tokens():
"""
Test that image_tokens are correctly costed when text_tokens=0.
Reproduces issue #17410: completion_cost calculates incorrectly for
Gemini-3-pro-image model - image_tokens were treated as text tokens
when text_tokens=0.
https://github.com/BerriAI/litellm/issues/17410
"""
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
litellm.model_cost = litellm.get_model_cost_map(url="")
model = "gemini-3-pro-image-preview"
custom_llm_provider = "vertex_ai"
# Usage from the issue: text_tokens=0, image_tokens=1120, reasoning_tokens=225
usage = Usage(
completion_tokens=1345,
prompt_tokens=10,
total_tokens=1355,
completion_tokens_details=CompletionTokensDetailsWrapper(
accepted_prediction_tokens=None,
audio_tokens=None,
reasoning_tokens=225,
rejected_prediction_tokens=None,
text_tokens=0, # This is the key: text_tokens=0
image_tokens=1120,
),
prompt_tokens_details=PromptTokensDetailsWrapper(
audio_tokens=None, cached_tokens=None, text_tokens=10, image_tokens=None
),
)
model_cost_map = litellm.model_cost[model]
prompt_cost, completion_cost = generic_cost_per_token(
model=model,
usage=usage,
custom_llm_provider=custom_llm_provider,
)
# Expected costs:
# - text_tokens: 0 * output_cost_per_token = 0
# - image_tokens: 1120 * output_cost_per_image_token = 1120 * 1.2e-04 = 0.1344
# - reasoning_tokens: 225 * output_cost_per_token = 225 * 1.2e-05 = 0.0027
# Total completion: ~0.1371
output_cost_per_image_token = model_cost_map.get("output_cost_per_image_token", 0)
output_cost_per_token = model_cost_map.get("output_cost_per_token", 0)
expected_image_cost = 1120 * output_cost_per_image_token
expected_reasoning_cost = 225 * output_cost_per_token # reasoning uses base token cost
expected_completion_cost = expected_image_cost + expected_reasoning_cost
# The bug was: all 1345 tokens were treated as text = 1345 * 1.2e-05 = 0.01614
# Fixed: image_tokens use image pricing = ~0.137
assert completion_cost > 0.10, (
f"Completion cost should be > $0.10 (image tokens are expensive), got ${completion_cost:.6f}. "
f"Bug: tokens may be incorrectly treated as text tokens."
)
assert round(completion_cost, 4) == round(expected_completion_cost, 4), (
f"Expected completion cost ${expected_completion_cost:.6f}, got ${completion_cost:.6f}"
)
def test_bedrock_anthropic_prompt_caching():
"""Test Bedrock Anthropic models with prompt caching return correct costs."""
model = "us.anthropic.claude-sonnet-4-5-20250929-v1:0"

View file

@ -1079,3 +1079,86 @@ def test_has_special_delta_attribute(
assert not initialized_custom_stream_wrapper._has_special_delta_attribute(
delta_with_none, "audio"
)
def test_is_chunk_non_empty_with_empty_tool_calls(
initialized_custom_stream_wrapper: CustomStreamWrapper,
):
"""
Test that is_chunk_non_empty returns False when tool_calls is an empty list.
Regression test for https://github.com/BerriAI/litellm/issues/17425
Empty tool_calls in delta should not be considered non-empty chunks.
"""
chunk = {
"id": "test-chunk-id",
"object": "chat.completion.chunk",
"created": 1741037890,
"model": "claude-sonnet-4-20250514",
"choices": [
{
"index": 0,
"delta": {
"content": None,
"tool_calls": [], # Empty tool_calls list
},
"logprobs": None,
"finish_reason": None,
}
],
}
# Empty tool_calls should return False
assert (
initialized_custom_stream_wrapper.is_chunk_non_empty(
completion_obj={}, # completion_obj has no tool_calls
model_response=ModelResponseStream(**chunk),
response_obj={},
)
is False
)
def test_is_chunk_non_empty_with_valid_tool_calls(
initialized_custom_stream_wrapper: CustomStreamWrapper,
):
"""
Test that is_chunk_non_empty returns True when tool_calls has valid entries.
Companion test for https://github.com/BerriAI/litellm/issues/17425
Non-empty tool_calls in delta should be considered non-empty chunks.
"""
chunk = {
"id": "test-chunk-id",
"object": "chat.completion.chunk",
"created": 1741037890,
"model": "claude-sonnet-4-20250514",
"choices": [
{
"index": 0,
"delta": {
"content": None,
"tool_calls": [
{
"id": "call_123",
"type": "function",
"function": {
"name": "get_weather",
"arguments": '{"location": "NYC"}',
},
}
],
},
"logprobs": None,
"finish_reason": None,
}
],
}
# Non-empty tool_calls should return True
assert (
initialized_custom_stream_wrapper.is_chunk_non_empty(
completion_obj={},
model_response=ModelResponseStream(**chunk),
response_obj={},
)
is True
)

View file

@ -290,3 +290,103 @@ class TestAnthropicBetaHeaderSupport:
else:
# If no beta headers, that's also fine
assert True
def test_converse_non_anthropic_model_no_anthropic_beta(self):
"""Test that non-Anthropic models (e.g., Qwen) do NOT get anthropic_beta in additionalModelRequestFields.
This is critical because non-Anthropic models on Bedrock will error with
"unknown variant anthropic_beta" if this field is included.
"""
config = AmazonConverseConfig()
# Even if headers contain anthropic-beta, non-Anthropic models should NOT get it
headers = {"anthropic-beta": "context-1m-2025-08-07,interleaved-thinking-2025-05-14"}
# Test with Qwen model (using ARN format like the user's config)
result = config._transform_request_helper(
model="qwen.qwen3-coder-480b-a35b-v1:0",
system_content_blocks=[],
optional_params={},
messages=[{"role": "user", "content": "Test"}],
headers=headers
)
additional_fields = result.get("additionalModelRequestFields", {})
assert "anthropic_beta" not in additional_fields, (
"anthropic_beta should NOT be added for non-Anthropic models like Qwen. "
"This field is only supported by Anthropic/Claude models on Bedrock."
)
def test_converse_llama_model_no_anthropic_beta(self):
"""Test that Llama models do NOT get anthropic_beta in additionalModelRequestFields."""
config = AmazonConverseConfig()
headers = {"anthropic-beta": "context-1m-2025-08-07"}
result = config._transform_request_helper(
model="meta.llama3-2-11b-instruct-v1:0",
system_content_blocks=[],
optional_params={},
messages=[{"role": "user", "content": "Test"}],
headers=headers
)
additional_fields = result.get("additionalModelRequestFields", {})
assert "anthropic_beta" not in additional_fields, (
"anthropic_beta should NOT be added for Llama models."
)
def test_converse_nova_model_no_anthropic_beta(self):
"""Test that Amazon Nova models do NOT get anthropic_beta in additionalModelRequestFields."""
config = AmazonConverseConfig()
headers = {"anthropic-beta": "computer-use-2024-10-22"}
result = config._transform_request_helper(
model="amazon.nova-pro-v1:0",
system_content_blocks=[],
optional_params={},
messages=[{"role": "user", "content": "Test"}],
headers=headers
)
additional_fields = result.get("additionalModelRequestFields", {})
assert "anthropic_beta" not in additional_fields, (
"anthropic_beta should NOT be added for Amazon Nova models."
)
def test_converse_anthropic_model_gets_anthropic_beta(self):
"""Test that Anthropic models DO get anthropic_beta in additionalModelRequestFields."""
config = AmazonConverseConfig()
headers = {"anthropic-beta": "context-1m-2025-08-07"}
result = config._transform_request_helper(
model="anthropic.claude-3-5-sonnet-20241022-v2:0",
system_content_blocks=[],
optional_params={},
messages=[{"role": "user", "content": "Test"}],
headers=headers
)
additional_fields = result.get("additionalModelRequestFields", {})
assert "anthropic_beta" in additional_fields, (
"anthropic_beta SHOULD be added for Anthropic models."
)
assert "context-1m-2025-08-07" in additional_fields["anthropic_beta"]
def test_converse_anthropic_model_with_cross_region_prefix(self):
"""Test that Anthropic models with cross-region prefix still get anthropic_beta."""
config = AmazonConverseConfig()
headers = {"anthropic-beta": "context-1m-2025-08-07"}
# Model with 'us.' cross-region prefix
result = config._transform_request_helper(
model="us.anthropic.claude-3-5-sonnet-20241022-v2:0",
system_content_blocks=[],
optional_params={},
messages=[{"role": "user", "content": "Test"}],
headers=headers
)
additional_fields = result.get("additionalModelRequestFields", {})
assert "anthropic_beta" in additional_fields, (
"anthropic_beta SHOULD be added for Anthropic models with cross-region prefix."
)
assert "context-1m-2025-08-07" in additional_fields["anthropic_beta"]

View file

@ -136,3 +136,327 @@ class TestOllamaModelInfo:
models = info.get_models()
# Default static ollama_models is ['llama2'], so expect ['ollama/llama2']
assert models == ["ollama/llama2"]
class TestOllamaAuthHeaders:
"""Tests for Ollama authentication header handling in completion calls."""
def test_ollama_completion_with_api_key_adds_auth_header(self, monkeypatch):
"""
Test that when an api_key is provided to ollama completion,
the Authorization header is added with Bearer token format.
This tests the bug fix where Ollama requests with API keys
were not including the Authorization header.
"""
import litellm
from unittest.mock import MagicMock, patch
# Track the headers that were passed to the completion call
captured_headers = {}
def mock_completion(*args, **kwargs):
# Capture the headers that were passed
if 'headers' in kwargs:
captured_headers.update(kwargs['headers'])
# Return a mock response
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message = MagicMock()
mock_response.choices[0].message.content = "Test response"
return mock_response
# Mock the base_llm_http_handler.completion method at the module level
with patch('litellm.main.base_llm_http_handler.completion', side_effect=mock_completion):
try:
# Call completion with ollama provider and api_key
litellm.completion(
model="ollama/llama2",
messages=[{"role": "user", "content": "Hello"}],
api_key="test-api-key-12345",
api_base="http://localhost:11434"
)
# Verify that Authorization header was added
assert "Authorization" in captured_headers, \
"Authorization header should be present when api_key is provided"
assert captured_headers["Authorization"] == "Bearer test-api-key-12345", \
f"Authorization header should be 'Bearer test-api-key-12345', got {captured_headers.get('Authorization')}"
except Exception as e:
pytest.fail(f"Ollama completion with api_key failed: {e}")
def test_ollama_chat_completion_with_api_key_adds_auth_header(self, monkeypatch):
"""
Test that when an api_key is provided to ollama_chat completion,
the Authorization header is added with Bearer token format.
This tests the bug fix for the ollama_chat provider variant.
"""
import litellm
from unittest.mock import MagicMock, patch
# Track the headers that were passed to the completion call
captured_headers = {}
def mock_completion(*args, **kwargs):
# Capture the headers that were passed
if 'headers' in kwargs:
captured_headers.update(kwargs['headers'])
# Return a mock response
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message = MagicMock()
mock_response.choices[0].message.content = "Test response"
return mock_response
# Mock the base_llm_http_handler.completion method at the module level
with patch('litellm.main.base_llm_http_handler.completion', side_effect=mock_completion):
try:
# Call completion with ollama_chat provider and api_key
litellm.completion(
model="ollama_chat/llama2",
messages=[{"role": "user", "content": "Hello"}],
api_key="test-api-key-67890",
api_base="http://localhost:11434"
)
# Verify that Authorization header was added
assert "Authorization" in captured_headers, \
"Authorization header should be present when api_key is provided"
assert captured_headers["Authorization"] == "Bearer test-api-key-67890", \
f"Authorization header should be 'Bearer test-api-key-67890', got {captured_headers.get('Authorization')}"
except Exception as e:
pytest.fail(f"Ollama_chat completion with api_key failed: {e}")
def test_ollama_completion_without_api_key_no_auth_header(self, monkeypatch):
"""
Test that when no api_key is provided to ollama completion,
no Authorization header is added.
"""
import litellm
from unittest.mock import MagicMock, patch
# Track the headers that were passed to the completion call
captured_headers = {}
def mock_completion(*args, **kwargs):
# Capture the headers that were passed
if 'headers' in kwargs:
captured_headers.update(kwargs['headers'])
# Return a mock response
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message = MagicMock()
mock_response.choices[0].message.content = "Test response"
return mock_response
# Mock the base_llm_http_handler.completion method at the module level
with patch('litellm.main.base_llm_http_handler.completion', side_effect=mock_completion):
try:
# Call completion without api_key
litellm.completion(
model="ollama/llama2",
messages=[{"role": "user", "content": "Hello"}],
api_base="http://localhost:11434"
)
# Verify that Authorization header was NOT added
assert "Authorization" not in captured_headers, \
"Authorization header should not be present when api_key is not provided"
except Exception as e:
pytest.fail(f"Ollama completion without api_key failed: {e}")
def test_ollama_completion_preserves_existing_auth_header(self, monkeypatch):
"""
Test that when an Authorization header is already present in headers,
it is not overwritten even if api_key is provided.
This ensures the fix respects existing Authorization headers.
"""
import litellm
from unittest.mock import MagicMock, patch
# Track the headers that were passed to the completion call
captured_headers = {}
def mock_completion(*args, **kwargs):
# Capture the headers that were passed
if 'headers' in kwargs:
captured_headers.update(kwargs['headers'])
# Return a mock response
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message = MagicMock()
mock_response.choices[0].message.content = "Test response"
return mock_response
# Mock the base_llm_http_handler.completion method at the module level
with patch('litellm.main.base_llm_http_handler.completion', side_effect=mock_completion):
try:
# Call completion with both api_key and existing Authorization header
existing_auth = "Bearer existing-token"
litellm.completion(
model="ollama/llama2",
messages=[{"role": "user", "content": "Hello"}],
api_key="test-api-key-should-not-be-used",
api_base="http://localhost:11434",
headers={"Authorization": existing_auth}
)
# Verify that existing Authorization header was preserved
assert "Authorization" in captured_headers, \
"Authorization header should be present"
assert captured_headers["Authorization"] == existing_auth, \
f"Existing Authorization header should be preserved, got {captured_headers.get('Authorization')}"
except Exception as e:
pytest.fail(f"Ollama completion with existing auth header failed: {e}")
def test_ollama_completion_with_ollama_com_api_base(self, monkeypatch):
"""
Test that when using https://ollama.com as api_base with an api_key,
the Authorization header is correctly added.
This tests the real-world use case of using Ollama's hosted service.
"""
import litellm
from unittest.mock import MagicMock, patch
# Track the headers and api_base that were passed to the completion call
captured_headers = {}
captured_api_base = None
def mock_completion(*args, **kwargs):
nonlocal captured_api_base
# Capture the headers and api_base that were passed
if 'headers' in kwargs:
captured_headers.update(kwargs['headers'])
if 'api_base' in kwargs:
captured_api_base = kwargs['api_base']
# Return a mock response
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message = MagicMock()
mock_response.choices[0].message.content = "Test response"
return mock_response
# Mock the base_llm_http_handler.completion method at the module level
with patch('litellm.main.base_llm_http_handler.completion', side_effect=mock_completion):
try:
# Call completion with ollama.com as api_base and api_key
litellm.completion(
model="ollama/qwen3-vl:235b-cloud",
messages=[{"role": "user", "content": "Hello"}],
api_key="test-ollama-com-api-key",
api_base="https://ollama.com"
)
# Verify that Authorization header was added
assert "Authorization" in captured_headers, \
"Authorization header should be present when using ollama.com with api_key"
assert captured_headers["Authorization"] == "Bearer test-ollama-com-api-key", \
f"Authorization header should be 'Bearer test-ollama-com-api-key', got {captured_headers.get('Authorization')}"
# Verify the api_base was passed correctly
assert captured_api_base == "https://ollama.com", \
f"API base should be 'https://ollama.com', got {captured_api_base}"
except Exception as e:
pytest.fail(f"Ollama completion with ollama.com api_base failed: {e}")
def test_ollama_chat_completion_with_ollama_com_api_base(self, monkeypatch):
"""
Test that when using https://ollama.com as api_base with an api_key
for ollama_chat provider, the Authorization header is correctly added.
This tests the real-world use case for the ollama_chat variant.
"""
import litellm
from unittest.mock import MagicMock, patch
# Track the headers and api_base that were passed to the completion call
captured_headers = {}
captured_api_base = None
def mock_completion(*args, **kwargs):
nonlocal captured_api_base
# Capture the headers and api_base that were passed
if 'headers' in kwargs:
captured_headers.update(kwargs['headers'])
if 'api_base' in kwargs:
captured_api_base = kwargs['api_base']
# Return a mock response
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message = MagicMock()
mock_response.choices[0].message.content = "Test response"
return mock_response
# Mock the base_llm_http_handler.completion method at the module level
with patch('litellm.main.base_llm_http_handler.completion', side_effect=mock_completion):
try:
# Call completion with ollama.com as api_base and api_key
litellm.completion(
model="ollama_chat/qwen3-vl:235b-cloud",
messages=[{"role": "user", "content": "Hello"}],
api_key="test-ollama-com-chat-key",
api_base="https://ollama.com"
)
# Verify that Authorization header was added
assert "Authorization" in captured_headers, \
"Authorization header should be present when using ollama.com with api_key"
assert captured_headers["Authorization"] == "Bearer test-ollama-com-chat-key", \
f"Authorization header should be 'Bearer test-ollama-com-chat-key', got {captured_headers.get('Authorization')}"
# Verify the api_base was passed correctly
assert captured_api_base == "https://ollama.com", \
f"API base should be 'https://ollama.com', got {captured_api_base}"
except Exception as e:
pytest.fail(f"Ollama_chat completion with ollama.com api_base failed: {e}")
def test_ollama_completion_with_ollama_com_without_api_key_fails_gracefully(self, monkeypatch):
"""
Test that when using https://ollama.com as api_base without an api_key,
no Authorization header is added (which would likely fail on the server side,
but we're testing the client behavior).
This ensures we don't add empty or None Authorization headers.
"""
import litellm
from unittest.mock import MagicMock, patch
# Track the headers that were passed to the completion call
captured_headers = {}
def mock_completion(*args, **kwargs):
# Capture the headers that were passed
if 'headers' in kwargs:
captured_headers.update(kwargs['headers'])
# Return a mock response
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message = MagicMock()
mock_response.choices[0].message.content = "Test response"
return mock_response
# Mock the base_llm_http_handler.completion method at the module level
with patch('litellm.main.base_llm_http_handler.completion', side_effect=mock_completion):
try:
# Call completion with ollama.com but no api_key
litellm.completion(
model="ollama/llama2",
messages=[{"role": "user", "content": "Hello"}],
api_base="https://ollama.com"
)
# Verify that Authorization header was NOT added
assert "Authorization" not in captured_headers, \
"Authorization header should not be present when api_key is not provided, even with ollama.com"
except Exception as e:
pytest.fail(f"Ollama completion with ollama.com without api_key failed: {e}")

View file

@ -390,13 +390,13 @@ def test_streaming_chunk_includes_reasoning_content():
)
def test_streaming_chunk_with_tool_calls_includes_reasoning_content():
def test_streaming_chunk_with_tool_calls_and_thought_includes_reasoning_content():
"""
Test for issue #16805: Ensure that when Gemini returns a streaming chunk with
tool calls AND thoughtSignature, the reasoning_content is included in the delta.
Test that when Gemini returns a streaming chunk with both thought: true parts
AND tool calls, the reasoning_content is correctly extracted from the thought parts.
Previously, thinking_blocks were only added to non-streaming responses, causing
reasoning_content to be missing in streaming mode when tools were enabled.
Per Google's docs: thought: true indicates reasoning content, NOT thoughtSignature.
thoughtSignature is just a token for multi-turn context preservation.
"""
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
ModelResponseIterator,
@ -409,12 +409,16 @@ def test_streaming_chunk_with_tool_calls_includes_reasoning_content():
{
"content": {
"parts": [
{
"text": "Let me think about how to get the time...",
"thought": True, # This indicates reasoning content
},
{
"functionCall": {
"name": "get_current_time",
"args": {"timezone": "America/New_York"},
},
"thoughtSignature": "EsEDCr4DAdHtim...", # Base64 signature
"thoughtSignature": "EsEDCr4DAdHtim...", # Just a token, not reasoning
}
]
},
@ -433,8 +437,8 @@ def test_streaming_chunk_with_tool_calls_includes_reasoning_content():
)
streaming_chunk = iterator.chunk_parser(chunk)
# Verify that reasoning_content is present in the streaming delta
assert streaming_chunk.choices[0].delta.reasoning_content is not None
# Verify reasoning_content comes from the thought: true part
assert streaming_chunk.choices[0].delta.reasoning_content == "Let me think about how to get the time..."
# Verify tool calls are also present
assert streaming_chunk.choices[0].delta.tool_calls is not None
@ -442,6 +446,59 @@ def test_streaming_chunk_with_tool_calls_includes_reasoning_content():
assert streaming_chunk.choices[0].delta.tool_calls[0].function.name == "get_current_time"
def test_streaming_chunk_with_tool_calls_no_thought_no_reasoning_content():
"""
Test that when Gemini returns tool calls with thoughtSignature but WITHOUT
thought: true, there is NO reasoning_content.
This is a regression test for the bug where functionCall data was incorrectly
being placed into reasoning_content when thoughtSignature was present.
Per Google's docs: thoughtSignature is just a token for multi-turn, not reasoning.
"""
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
ModelResponseIterator,
)
litellm_logging = MagicMock()
chunk = {
"candidates": [
{
"content": {
"parts": [
{
"functionCall": {
"name": "get_current_time",
"args": {"timezone": "America/New_York"},
},
"thoughtSignature": "EsEDCr4DAdHtim...", # Just a token, NOT thought: true
}
]
},
"finishReason": "STOP",
}
],
"usageMetadata": {
"promptTokenCount": 68,
"candidatesTokenCount": 120,
"totalTokenCount": 188,
},
}
iterator = ModelResponseIterator(
streaming_response=[], sync_stream=True, logging_obj=litellm_logging
)
streaming_chunk = iterator.chunk_parser(chunk)
# reasoning_content should be None - thoughtSignature alone does NOT mean reasoning
assert getattr(streaming_chunk.choices[0].delta, 'reasoning_content', None) is None
# Tool calls should still work
assert streaming_chunk.choices[0].delta.tool_calls is not None
assert len(streaming_chunk.choices[0].delta.tool_calls) == 1
assert streaming_chunk.choices[0].delta.tool_calls[0].function.name == "get_current_time"
def test_check_finish_reason():
finish_reason_mappings = VertexGeminiConfig.get_finish_reason_mapping()
for k, v in finish_reason_mappings.items():

View file

@ -0,0 +1,87 @@
"""
Tests for MCP metadata preservation.
This module tests that tool metadata is preserved when creating prefixed tools,
which is critical for ChatGPT UI widget rendering.
"""
import sys
import pytest
# Add the parent directory to the path so we can import litellm
sys.path.insert(0, "../../../../../")
from mcp.types import Tool as MCPTool
from litellm.proxy._experimental.mcp_server.mcp_server_manager import MCPServerManager
from litellm.proxy._types import MCPTransport
from litellm.types.mcp_server.mcp_server_manager import MCPServer
class TestMCPMetadataPreservation:
"""Test that metadata is preserved when creating prefixed tools"""
def test_create_prefixed_tools_preserves_metadata(self):
"""Test that _create_prefixed_tools preserves metadata and _meta fields"""
manager = MCPServerManager()
# Create a mock server
mock_server = MCPServer(
server_id="test-server-1",
name="test_server",
alias="test",
server_name="Test Server",
url="https://test-server.com/mcp",
transport=MCPTransport.http,
)
# Create a tool with metadata
tool_with_metadata = MCPTool(
name="hello_widget",
description="Display a greeting widget",
inputSchema={"type": "object", "properties": {}},
)
# Add metadata using setattr since MCPTool might not have it in the constructor
tool_with_metadata.metadata = {
"openai/outputTemplate": "ui://widget/hello.html",
"openai/widgetDescription": "A greeting widget",
}
tool_with_metadata._meta = {
"openai/toolInvocation/invoking": "Preparing greeting...",
}
# Create prefixed tools
prefixed_tools = manager._create_prefixed_tools(
[tool_with_metadata], mock_server, add_prefix=True
)
# Verify
assert len(prefixed_tools) == 1
prefixed_tool = prefixed_tools[0]
# Check that name is prefixed
assert prefixed_tool.name == "test-hello_widget"
# Check that metadata is preserved
assert hasattr(prefixed_tool, "metadata")
assert prefixed_tool.metadata == {
"openai/outputTemplate": "ui://widget/hello.html",
"openai/widgetDescription": "A greeting widget",
}
# Check that _meta is preserved
assert hasattr(prefixed_tool, "_meta")
assert prefixed_tool._meta == {
"openai/toolInvocation/invoking": "Preparing greeting...",
}
# Check that other fields are preserved
assert prefixed_tool.description == "Display a greeting widget"
assert prefixed_tool.inputSchema == {"type": "object", "properties": {}}
if __name__ == "__main__":
pytest.main([__file__])

View file

@ -10,7 +10,13 @@ sys.path.insert(0, "../../../../../")
import httpx
from mcp import ReadResourceResult, Resource
from mcp.types import GetPromptResult, Prompt, ResourceTemplate, TextResourceContents
from mcp.types import (
GetPromptResult,
Prompt,
ResourceTemplate,
TextResourceContents,
Tool as MCPTool,
)
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
MCPServerManager,
@ -995,10 +1001,11 @@ class TestMCPServerManager:
manager._create_mcp_client = MagicMock(return_value=object())
# Tools returned upstream (unprefixed from provider)
upstream_tool = MagicMock()
upstream_tool.name = "send_email"
upstream_tool.description = "Send an email"
upstream_tool.inputSchema = {}
upstream_tool = MCPTool(
name="send_email",
description="Send an email",
inputSchema={},
)
manager._fetch_tools_with_timeout = AsyncMock(return_value=[upstream_tool])
@ -1025,14 +1032,16 @@ class TestMCPServerManager:
)
# Input tools as would come from upstream
t1 = MagicMock()
t1.name = "create_issue"
t1.description = ""
t1.inputSchema = {}
t2 = MagicMock()
t2.name = "close_issue"
t2.description = ""
t2.inputSchema = {}
t1 = MCPTool(
name="create_issue",
description="",
inputSchema={},
)
t2 = MCPTool(
name="close_issue",
description="",
inputSchema={},
)
# Do not add prefix in returned objects
out_tools = manager._create_prefixed_tools([t1, t2], server, add_prefix=False)
@ -1066,10 +1075,11 @@ class TestMCPServerManager:
manager.registry = {server.server_id: server}
# Populate mapping (add_prefix value doesn't matter for mapping population)
base_tool = MagicMock()
base_tool.name = "create_zap"
base_tool.description = ""
base_tool.inputSchema = {}
base_tool = MCPTool(
name="create_zap",
description="",
inputSchema={},
)
_ = manager._create_prefixed_tools([base_tool], server, add_prefix=False)
# Unprefixed resolution

View file

@ -572,4 +572,77 @@ async def test_add_spend_log_transaction_to_daily_org_transaction_skips_when_org
org_id=None,
)
writer.daily_org_spend_update_queue.add_update.assert_not_called()
writer.daily_org_spend_update_queue.add_update.assert_not_called()
@pytest.mark.asyncio
async def test_add_spend_log_transaction_to_daily_end_user_transaction_injects_end_user_id_and_queues_update():
writer = DBSpendUpdateWriter()
mock_prisma = MagicMock()
mock_prisma.get_request_status = MagicMock(return_value="success")
end_user_id = "end-user-xyz"
payload = {
"request_id": "req-1",
"user": "test-user",
"end_user": end_user_id,
"startTime": "2024-01-01T12:00:00",
"api_key": "test-key",
"model": "gpt-4",
"custom_llm_provider": "openai",
"model_group": "gpt-4-group",
"prompt_tokens": 10,
"completion_tokens": 5,
"spend": 0.2,
"metadata": '{"usage_object": {}}',
}
writer.daily_end_user_spend_update_queue.add_update = AsyncMock()
await writer.add_spend_log_transaction_to_daily_end_user_transaction(
payload=payload,
prisma_client=mock_prisma,
)
writer.daily_end_user_spend_update_queue.add_update.assert_called_once()
call_args = writer.daily_end_user_spend_update_queue.add_update.call_args[1]
update_dict = call_args["update"]
assert len(update_dict) == 1
for key, transaction in update_dict.items():
assert key == f"{end_user_id}_2024-01-01_test-key_gpt-4_openai"
assert transaction["end_user_id"] == end_user_id
assert transaction["date"] == "2024-01-01"
assert transaction["api_key"] == "test-key"
assert transaction["model"] == "gpt-4"
assert transaction["custom_llm_provider"] == "openai"
@pytest.mark.asyncio
async def test_add_spend_log_transaction_to_daily_end_user_transaction_skips_when_end_user_id_missing():
writer = DBSpendUpdateWriter()
mock_prisma = MagicMock()
mock_prisma.get_request_status = MagicMock(return_value="success")
payload = {
"request_id": "req-2",
"user": "test-user",
"startTime": "2024-01-01T12:00:00",
"api_key": "test-key",
"model": "gpt-4",
"custom_llm_provider": "openai",
"model_group": "gpt-4-group",
"prompt_tokens": 10,
"completion_tokens": 5,
"spend": 0.2,
"metadata": '{"usage_object": {}}',
}
writer.daily_end_user_spend_update_queue.add_update = AsyncMock()
await writer.add_spend_log_transaction_to_daily_end_user_transaction(
payload=payload,
prisma_client=mock_prisma,
)
writer.daily_end_user_spend_update_queue.add_update.assert_not_called()

View file

@ -634,6 +634,228 @@ async def test_request_data_flows_to_apply_guardrail():
print("✓ request_data correctly passed to apply_guardrail")
@pytest.mark.asyncio
async def test_empty_content_handling(presidio_guardrail, mock_user_api_key, mock_cache):
"""
Test that Presidio handles empty content gracefully.
This is common in tool/function calling where assistant messages have
empty content but include tool_calls.
Bug fix: Previously crashed with:
TypeError: argument after ** must be a mapping, not str
"""
test_data = {
"messages": [
{"role": "user", "content": "What is 2+2?"},
{
"role": "assistant",
"content": "", # Empty content - common in tool calls
"tool_calls": [
{
"id": "call_123",
"type": "function",
"function": {"name": "calculator", "arguments": '{"a":2,"b":2}'},
}
],
},
{"role": "tool", "tool_call_id": "call_123", "content": "4"},
],
"model": "gpt-4",
}
# Mock check_pii to simulate PII processing without needing Presidio API
async def mock_check_pii(text, output_parse_pii, presidio_config, request_data):
# Empty text returns as-is (this is what our fix ensures)
return text
presidio_guardrail.check_pii = mock_check_pii
# This should not raise an exception
result = await presidio_guardrail.async_pre_call_hook(
user_api_key_dict=mock_user_api_key,
cache=mock_cache,
data=test_data,
call_type="completion",
)
assert result is not None
assert "messages" in result
# Verify messages are preserved
assert len(result["messages"]) == 3
print("✓ Empty content handling test passed")
@pytest.mark.asyncio
async def test_whitespace_only_content(presidio_guardrail, mock_user_api_key, mock_cache):
"""
Test that Presidio handles whitespace-only content gracefully.
Whitespace-only content should be treated the same as empty content.
"""
test_data = {
"messages": [
{"role": "user", "content": " "}, # Whitespace only
{"role": "assistant", "content": "\n\t "}, # Tabs and newlines
{"role": "user", "content": "Real question here"},
],
"model": "gpt-4",
}
# Mock check_pii to simulate PII processing
async def mock_check_pii(text, output_parse_pii, presidio_config, request_data):
return text
presidio_guardrail.check_pii = mock_check_pii
result = await presidio_guardrail.async_pre_call_hook(
user_api_key_dict=mock_user_api_key,
cache=mock_cache,
data=test_data,
call_type="completion",
)
assert result is not None
assert len(result["messages"]) == 3
print("✓ Whitespace-only content test passed")
@pytest.mark.asyncio
async def test_analyze_text_with_empty_string():
"""
Test analyze_text method directly with empty string.
Should return empty list without making API call to Presidio.
"""
presidio = _OPTIONAL_PresidioPIIMasking(
presidio_analyzer_api_base="http://test:5002/",
presidio_anonymizer_api_base="http://test:5001/",
output_parse_pii=False,
)
# Test with empty string - should return immediately without API call
result = await presidio.analyze_text(
text="",
presidio_config=None,
request_data={},
)
assert result == [], "Empty text should return empty list"
# Test with whitespace only - should return immediately
result = await presidio.analyze_text(
text=" \n\t ",
presidio_config=None,
request_data={},
)
assert result == [], "Whitespace-only text should return empty list"
print("✓ analyze_text empty string test passed")
@pytest.mark.asyncio
async def test_analyze_text_error_dict_handling():
"""
Test that analyze_text handles error dict responses from Presidio API.
When Presidio returns {'error': 'No text provided'}, should handle gracefully
instead of crashing with TypeError.
"""
presidio = _OPTIONAL_PresidioPIIMasking(
presidio_analyzer_api_base="http://mock-presidio:5002/",
presidio_anonymizer_api_base="http://mock-presidio:5001/",
output_parse_pii=False,
)
# Mock the HTTP response to return error dict
class MockResponse:
async def json(self):
return {"error": "No text provided"}
async def __aenter__(self):
return self
async def __aexit__(self, *args):
pass
class MockSession:
def post(self, *args, **kwargs):
return MockResponse()
async def __aenter__(self):
return self
async def __aexit__(self, *args):
pass
with patch("aiohttp.ClientSession", return_value=MockSession()):
result = await presidio.analyze_text(
text="some text",
presidio_config=None,
request_data={},
)
# Should return empty list when error dict is received
assert result == [], "Error dict should be handled gracefully"
print("✓ analyze_text error dict handling test passed")
@pytest.mark.asyncio
async def test_tool_calling_complete_scenario(presidio_guardrail, mock_user_api_key, mock_cache):
"""
Test complete tool calling scenario with PII in user message.
This tests the real-world scenario where:
1. User provides a query with PII
2. Assistant responds with empty content + tool_calls
3. Tool provides response
4. Assistant provides final answer
"""
test_data = {
"messages": [
{
"role": "user",
"content": "My email is john.doe@example.com. Can you look up my account?",
},
{
"role": "assistant",
"content": "", # Empty - tool call
"tool_calls": [
{
"id": "call_abc",
"type": "function",
"function": {"name": "lookup_account", "arguments": "{}"},
}
],
},
{"role": "tool", "tool_call_id": "call_abc", "content": "Account found"},
{"role": "assistant", "content": "I found your account information."},
],
"model": "gpt-4",
}
# Mock check_pii to simulate PII masking
async def mock_check_pii(text, output_parse_pii, presidio_config, request_data):
if "john.doe@example.com" in text:
return text.replace("john.doe@example.com", "[EMAIL]")
return text
presidio_guardrail.check_pii = mock_check_pii
result = await presidio_guardrail.async_pre_call_hook(
user_api_key_dict=mock_user_api_key,
cache=mock_cache,
data=test_data,
call_type="completion",
)
assert result is not None
# Verify PII was masked in user message
assert "[EMAIL]" in result["messages"][0]["content"]
assert "john.doe@example.com" not in result["messages"][0]["content"]
# Verify other messages preserved
assert len(result["messages"]) == 4
print("✓ Tool calling complete scenario test passed")
if __name__ == "__main__":
# Run tests
asyncio.run(

View file

@ -1323,3 +1323,187 @@ async def test_default_priority_shared_pool():
print(f" - 3 keys without priority share ONE pool: {desc_a[0]['value']}")
print(f" - Shared pool limit: {desc_a[0]['rate_limit']['requests_per_unit']} RPM")
print(f" - Explicit priority 'prod' uses separate pool: {desc_prod[0]['value']}")
@pytest.mark.asyncio
async def test_async_log_success_event_increments_by_actual_tokens():
"""
Test that async_log_success_event increments token counters by actual token usage.
This validates the fix for Bug 1: Token count was incrementing by 1 instead of actual usage.
The async_log_success_event should increment both model_saturation_check and priority_model
counters by the actual completion_tokens (when rate_limit_type=output).
"""
from unittest.mock import MagicMock
from litellm.types.utils import ModelResponse, Usage
os.environ["LITELLM_LICENSE"] = "test-license-key"
litellm.priority_reservation = {"dev": 0.1, "prod": 0.9}
dual_cache = DualCache()
handler = DynamicRateLimitHandler(internal_usage_cache=dual_cache)
model = "test-token-increment"
llm_router = Router(
model_list=[
{
"model_name": model,
"litellm_params": {
"model": "gpt-3.5-turbo",
"api_key": "test-key",
"api_base": "test-base",
"tpm": 1000,
},
}
]
)
handler.update_variables(llm_router=llm_router)
# Track what gets incremented
increment_calls = []
async def mock_increment(pipeline_operations, parent_otel_span=None):
for op in pipeline_operations:
increment_calls.append({
"key": op["key"],
"increment_value": op["increment_value"],
})
handler.v3_limiter.async_increment_tokens_with_ttl_preservation = mock_increment
# Create mock response with 50 completion tokens
mock_response = MagicMock(spec=ModelResponse)
mock_response.usage = MagicMock(spec=Usage)
mock_response.usage.prompt_tokens = 10
mock_response.usage.completion_tokens = 50
mock_response.usage.total_tokens = 60
# Create kwargs with priority in user_api_key_auth_metadata
kwargs = {
"standard_logging_object": {
"metadata": {
"user_api_key_auth_metadata": {"priority": "dev"},
},
"model_group": model,
},
"litellm_params": {
"metadata": {"model_group": model},
},
}
with patch(
"litellm.proxy.common_utils.callback_utils.get_model_group_from_litellm_kwargs",
return_value=model,
):
await handler.async_log_success_event(
kwargs=kwargs,
response_obj=mock_response,
start_time=None,
end_time=None,
)
# Verify increments happened with actual token count (50 completion tokens)
assert len(increment_calls) == 2, f"Expected 2 increment calls, got {len(increment_calls)}"
# Both should increment by 50 (completion_tokens, since rate_limit_type defaults to 'output')
for call in increment_calls:
assert call["increment_value"] == 50, (
f"Expected increment of 50 tokens, got {call['increment_value']} for key {call['key']}"
)
# Verify correct keys were used
keys = [call["key"] for call in increment_calls]
assert any("model_saturation_check" in k for k in keys), "Should increment model_saturation_check"
assert any("priority_model" in k and "dev" in k for k in keys), "Should increment priority_model with 'dev' priority"
@pytest.mark.asyncio
async def test_async_log_success_event_uses_team_priority_from_auth_metadata():
"""
Test that async_log_success_event correctly retrieves priority from user_api_key_auth_metadata.
This validates the fix where priority is retrieved from standard_logging_metadata.user_api_key_auth_metadata
instead of just standard_logging_metadata.priority. This is important for team-based priority inheritance.
"""
from unittest.mock import MagicMock
from litellm.types.utils import ModelResponse, Usage
os.environ["LITELLM_LICENSE"] = "test-license-key"
litellm.priority_reservation = {"team_priority": 0.8, "default": 0.2}
dual_cache = DualCache()
handler = DynamicRateLimitHandler(internal_usage_cache=dual_cache)
model = "test-team-priority"
llm_router = Router(
model_list=[
{
"model_name": model,
"litellm_params": {
"model": "gpt-3.5-turbo",
"api_key": "test-key",
"api_base": "test-base",
"tpm": 1000,
},
}
]
)
handler.update_variables(llm_router=llm_router)
# Track incremented keys to verify priority is used correctly
incremented_keys = []
async def mock_increment(pipeline_operations, parent_otel_span=None):
for op in pipeline_operations:
incremented_keys.append(op["key"])
handler.v3_limiter.async_increment_tokens_with_ttl_preservation = mock_increment
# Create mock response
mock_response = MagicMock(spec=ModelResponse)
mock_response.usage = MagicMock(spec=Usage)
mock_response.usage.prompt_tokens = 10
mock_response.usage.completion_tokens = 20
mock_response.usage.total_tokens = 30
# Simulate team metadata inheritance: priority is in user_api_key_auth_metadata
# This is how the proxy passes team metadata to the callback
kwargs = {
"standard_logging_object": {
"metadata": {
# Priority NOT at top level (this would fail before the fix)
# Priority IS in user_api_key_auth_metadata (team inheritance)
"user_api_key_auth_metadata": {"priority": "team_priority"},
},
"model_group": model,
},
"litellm_params": {
"metadata": {"model_group": model},
},
}
with patch(
"litellm.proxy.common_utils.callback_utils.get_model_group_from_litellm_kwargs",
return_value=model,
):
await handler.async_log_success_event(
kwargs=kwargs,
response_obj=mock_response,
start_time=None,
end_time=None,
)
# Verify the priority_model key uses 'team_priority' (not 'default_pool')
priority_keys = [k for k in incremented_keys if "priority_model" in k]
assert len(priority_keys) == 1, f"Expected 1 priority_model key, got {len(priority_keys)}"
# The key should contain 'team_priority', not 'default_pool'
assert "team_priority" in priority_keys[0], (
f"Expected priority key to use 'team_priority' from user_api_key_auth_metadata, "
f"got key: {priority_keys[0]}"
)
assert "default_pool" not in priority_keys[0], (
f"Priority key should NOT use 'default_pool', should use team's priority. Got: {priority_keys[0]}"
)

View file

@ -0,0 +1,130 @@
"""
Tests for KeyManagementEventHooks.
Validates that email and secret manager operations are independent and non-blocking.
"""
import os
import sys
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
sys.path.insert(0, os.path.abspath("../../../.."))
from litellm.proxy.hooks.key_management_event_hooks import KeyManagementEventHooks
class TestKeyManagementEventHooksIndependentOperations:
"""Tests that email and secret manager operations are independent."""
@pytest.mark.asyncio
async def test_email_failure_does_not_block_secret_manager(self):
"""
Test that if email sending fails, secret manager operation still runs.
This validates the independent operation design where one failure
does not block the other operation.
"""
secret_manager_called = {"called": False}
# Mock the email method to raise an exception
async def mock_send_email_raises(*args, **kwargs):
raise Exception("Email service unavailable")
# Mock the secret manager method to track if it was called
async def mock_store_secret(*args, **kwargs):
secret_manager_called["called"] = True
# Create mock objects for the hook parameters
mock_data = MagicMock()
mock_data.key_alias = "test-key-alias"
mock_response = MagicMock()
mock_response.model_dump.return_value = {"key": "sk-test", "token": "test-token"}
mock_response.model_dump_json.return_value = '{"key": "sk-test"}'
mock_response.token_id = "token-123"
mock_response.key = "sk-test-key"
mock_user_api_key_dict = MagicMock()
mock_user_api_key_dict.user_id = "user-123"
mock_user_api_key_dict.api_key = "api-key-123"
with patch.object(
KeyManagementEventHooks,
"_send_key_created_email",
side_effect=mock_send_email_raises,
), patch.object(
KeyManagementEventHooks,
"_store_virtual_key_in_secret_manager",
side_effect=mock_store_secret,
), patch(
"litellm.store_audit_logs", False
), patch(
"litellm.proxy.hooks.key_management_event_hooks.verbose_proxy_logger"
):
# Should not raise even though email fails
await KeyManagementEventHooks.async_key_generated_hook(
data=mock_data,
response=mock_response,
user_api_key_dict=mock_user_api_key_dict,
)
# Secret manager should have been called despite email failure
assert secret_manager_called["called"] is True
@pytest.mark.asyncio
async def test_secret_manager_failure_does_not_block_email(self):
"""
Test that if secret manager fails, email operation still runs.
This validates the independent operation design where one failure
does not block the other operation.
"""
email_called = {"called": False}
# Mock the email method to track if it was called
async def mock_send_email(*args, **kwargs):
email_called["called"] = True
# Mock the secret manager method to raise an exception
async def mock_store_secret_raises(*args, **kwargs):
raise Exception("Secret manager unavailable")
# Create mock objects for the hook parameters
mock_data = MagicMock()
mock_data.key_alias = "test-key-alias"
mock_response = MagicMock()
mock_response.model_dump.return_value = {"key": "sk-test", "token": "test-token"}
mock_response.model_dump_json.return_value = '{"key": "sk-test"}'
mock_response.token_id = "token-123"
mock_response.key = "sk-test-key"
mock_user_api_key_dict = MagicMock()
mock_user_api_key_dict.user_id = "user-123"
mock_user_api_key_dict.api_key = "api-key-123"
with patch.object(
KeyManagementEventHooks,
"_send_key_created_email",
side_effect=mock_send_email,
), patch.object(
KeyManagementEventHooks,
"_store_virtual_key_in_secret_manager",
side_effect=mock_store_secret_raises,
), patch(
"litellm.store_audit_logs", False
), patch(
"litellm.proxy.hooks.key_management_event_hooks.verbose_proxy_logger"
):
# Should not raise even though secret manager fails
await KeyManagementEventHooks.async_key_generated_hook(
data=mock_data,
response=mock_response,
user_api_key_dict=mock_user_api_key_dict,
)
# Email should have been called despite secret manager failure
assert email_called["called"] is True

View file

@ -1,4 +1,4 @@
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import FastAPI, HTTPException, Request, status
@ -301,3 +301,99 @@ def test_customer_endpoints_error_schema_consistency(mock_prisma_client, mock_us
for key in ["message", "type", "code"]:
assert isinstance(error1[key], str), f"error1[{key}] should be a string"
assert isinstance(error2[key], str), f"error2[{key}] should be a string"
@pytest.mark.asyncio
async def test_get_customer_daily_activity_admin_param_passing(monkeypatch):
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.management_endpoints import customer_endpoints
from litellm.proxy.management_endpoints.customer_endpoints import (
get_customer_daily_activity,
)
mock_prisma_client = AsyncMock()
mock_prisma_client.db.litellm_endusertable.find_many = AsyncMock(
return_value=[]
)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
mocked_response = MagicMock(name="SpendAnalyticsPaginatedResponse")
get_daily_activity_mock = AsyncMock(return_value=mocked_response)
monkeypatch.setattr(
customer_endpoints, "get_daily_activity", get_daily_activity_mock
)
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin1")
result = await get_customer_daily_activity(
end_user_ids="end-user-1,end-user-2",
start_date="2024-01-01",
end_date="2024-01-31",
model="gpt-4",
api_key="test-key",
page=2,
page_size=5,
exclude_end_user_ids="end-user-3",
user_api_key_dict=auth,
)
get_daily_activity_mock.assert_awaited_once()
kwargs = get_daily_activity_mock.call_args.kwargs
assert kwargs["table_name"] == "litellm_dailyenduserspend"
assert kwargs["entity_id_field"] == "end_user_id"
assert kwargs["entity_id"] == ["end-user-1", "end-user-2"]
assert kwargs["exclude_entity_ids"] == ["end-user-3"]
assert kwargs["start_date"] == "2024-01-01"
assert kwargs["end_date"] == "2024-01-31"
assert kwargs["model"] == "gpt-4"
assert kwargs["api_key"] == "test-key"
assert kwargs["page"] == 2
assert kwargs["page_size"] == 5
assert result is mocked_response
@pytest.mark.asyncio
async def test_get_customer_daily_activity_with_end_user_aliases(monkeypatch):
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.management_endpoints import customer_endpoints
from litellm.proxy.management_endpoints.customer_endpoints import (
get_customer_daily_activity,
)
mock_prisma_client = AsyncMock()
mock_end_user1 = MagicMock()
mock_end_user1.user_id = "end-user-1"
mock_end_user1.alias = "Customer One"
mock_end_user2 = MagicMock()
mock_end_user2.user_id = "end-user-2"
mock_end_user2.alias = "Customer Two"
mock_prisma_client.db.litellm_endusertable.find_many = AsyncMock(
return_value=[mock_end_user1, mock_end_user2]
)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
mocked_response = MagicMock(name="SpendAnalyticsPaginatedResponse")
get_daily_activity_mock = AsyncMock(return_value=mocked_response)
monkeypatch.setattr(
customer_endpoints, "get_daily_activity", get_daily_activity_mock
)
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin1")
await get_customer_daily_activity(
end_user_ids="end-user-1,end-user-2",
start_date="2024-01-01",
end_date="2024-01-31",
model=None,
api_key=None,
page=1,
page_size=10,
exclude_end_user_ids=None,
user_api_key_dict=auth,
)
kwargs = get_daily_activity_mock.call_args.kwargs
assert kwargs["entity_metadata_field"] == {
"end-user-1": {"alias": "Customer One"},
"end-user-2": {"alias": "Customer Two"},
}

View file

@ -16,7 +16,9 @@ sys.path.insert(
) # Adds the parent directory to the system path
from litellm.proxy._types import UserAPIKeyAuth # Import UserAPIKeyAuth
from litellm.proxy._types import (
LiteLLM_OrganizationMembershipTable,
LiteLLM_OrganizationTable,
LiteLLM_OrganizationTableWithMembers,
LiteLLM_TeamTable,
LitellmUserRoles,
Member,
@ -95,7 +97,7 @@ async def test_validate_team_org_change_same_org_id():
team.members_with_roles = []
# Mock organization
organization = MagicMock(spec=LiteLLM_OrganizationTable)
organization = MagicMock(spec=LiteLLM_OrganizationTableWithMembers)
organization.organization_id = org_id
organization.models = []
organization.litellm_budget_table = MagicMock()
@ -108,7 +110,7 @@ async def test_validate_team_org_change_same_org_id():
organization.litellm_budget_table.rpm_limit = (
50 # This would normally fail validation
)
organization.users = []
organization.members = []
# Mock Router
mock_router = MagicMock(spec=Router)
@ -126,6 +128,114 @@ async def test_validate_team_org_change_same_org_id():
mock_access_check.assert_not_called() # Ensure access check wasn't called
@pytest.mark.asyncio
async def test_validate_team_org_change_members_in_org():
"""
Test that validate_team_org_change passes when team members are in organization.members.
This tests the fix for issue #17552 where membership was incorrectly checked against
organization.users (deprecated) instead of organization.members (correct).
"""
team_org_id = "team-org-123"
new_org_id = "new-org-456"
user_id_1 = "user-123"
user_id_2 = "user-456"
# Mock team with members
team = MagicMock(spec=LiteLLM_TeamTable)
team.organization_id = team_org_id
team.models = []
team.max_budget = None
team.tpm_limit = None
team.rpm_limit = None
# Create mock team members
team_member_1 = MagicMock()
team_member_1.user_id = user_id_1
team_member_2 = MagicMock()
team_member_2.user_id = user_id_2
team.members_with_roles = [team_member_1, team_member_2]
# Mock organization with members (using LiteLLM_OrganizationMembershipTable structure)
organization = MagicMock(spec=LiteLLM_OrganizationTableWithMembers)
organization.organization_id = new_org_id
organization.models = []
organization.litellm_budget_table = None
# Create mock organization members - these should match team members
org_member_1 = MagicMock(spec=LiteLLM_OrganizationMembershipTable)
org_member_1.user_id = user_id_1
org_member_2 = MagicMock(spec=LiteLLM_OrganizationMembershipTable)
org_member_2.user_id = user_id_2
organization.members = [org_member_1, org_member_2]
# Mock Router
mock_router = MagicMock(spec=Router)
# Test should pass - all team members are in org members
result = validate_team_org_change(
team=team, organization=organization, llm_router=mock_router
)
assert result is True
@pytest.mark.asyncio
async def test_validate_team_org_change_member_not_in_org():
"""
Test that validate_team_org_change raises HTTPException when team members
are NOT in organization.members.
This tests the fix for issue #17552 where membership was incorrectly checked against
organization.users (deprecated) instead of organization.members (correct).
"""
team_org_id = "team-org-123"
new_org_id = "new-org-456"
user_id_1 = "user-123"
user_id_2 = "user-456"
user_id_not_in_org = "user-not-in-org-789"
# Mock team with members (including one not in org)
team = MagicMock(spec=LiteLLM_TeamTable)
team.organization_id = team_org_id
team.models = []
team.max_budget = None
team.tpm_limit = None
team.rpm_limit = None
# Create mock team members - user_id_not_in_org is not in the org
team_member_1 = MagicMock()
team_member_1.user_id = user_id_1
team_member_2 = MagicMock()
team_member_2.user_id = user_id_not_in_org
team.members_with_roles = [team_member_1, team_member_2]
# Mock organization with members (missing user_id_not_in_org)
organization = MagicMock(spec=LiteLLM_OrganizationTableWithMembers)
organization.organization_id = new_org_id
organization.models = []
organization.litellm_budget_table = None
# Create mock organization members - only user_id_1 and user_id_2 are members
org_member_1 = MagicMock(spec=LiteLLM_OrganizationMembershipTable)
org_member_1.user_id = user_id_1
org_member_2 = MagicMock(spec=LiteLLM_OrganizationMembershipTable)
org_member_2.user_id = user_id_2
organization.members = [org_member_1, org_member_2]
# Mock Router
mock_router = MagicMock(spec=Router)
# Test should fail - user_id_not_in_org is not in org members
with pytest.raises(HTTPException) as exc_info:
validate_team_org_change(
team=team, organization=organization, llm_router=mock_router
)
assert exc_info.value.status_code == 403
assert "not a member of the organization" in str(exc_info.value.detail)
assert user_id_not_in_org in str(exc_info.value.detail)
# Test for /team/permissions_list endpoint (GET)
@pytest.mark.asyncio
async def test_get_team_permissions_list_success(mock_db_client, mock_admin_auth):

View file

@ -91,6 +91,7 @@ def test_login_v2_returns_redirect_url_and_sets_cookie(monkeypatch):
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {})
monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", False)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
monkeypatch.setattr("litellm.proxy.utils.get_server_root_path", lambda: "")
client = TestClient(app)
response = client.post(

View file

@ -0,0 +1,172 @@
"""
Unit tests for Responses API image generation support
Tests the fix for Issue #16227:
https://github.com/BerriAI/litellm/issues/16227
Verifies that image generation outputs are correctly transformed
from /chat/completions format to /responses API format.
"""
import pytest
from unittest.mock import Mock
from litellm.responses.litellm_completion_transformation.transformation import (
LiteLLMCompletionResponsesConfig,
)
from litellm.types.responses.main import OutputImageGenerationCall
from litellm.types.utils import ModelResponse, Choices, Message
class TestExtractBase64FromDataUrl:
"""Tests for _extract_base64_from_data_url helper function"""
def test_extracts_base64_from_data_url(self):
"""Should extract pure base64 from data URL with prefix"""
data_url = "data:image/png;base64,iVBORw0KGgoAAAANS"
result = LiteLLMCompletionResponsesConfig._extract_base64_from_data_url(
data_url
)
assert result == "iVBORw0KGgoAAAANS"
def test_returns_base64_as_is_if_no_prefix(self):
"""Should return base64 as-is if no data: prefix"""
pure_base64 = "iVBORw0KGgoAAAANS"
result = LiteLLMCompletionResponsesConfig._extract_base64_from_data_url(
pure_base64
)
assert result == pure_base64
def test_handles_invalid_inputs(self):
"""Should return None for empty/None/malformed inputs"""
assert LiteLLMCompletionResponsesConfig._extract_base64_from_data_url("") is None
assert LiteLLMCompletionResponsesConfig._extract_base64_from_data_url(None) is None
assert LiteLLMCompletionResponsesConfig._extract_base64_from_data_url("data:image/png;base64") is None
class TestExtractImageGenerationOutputItems:
"""Tests for _extract_image_generation_output_items function"""
def test_extracts_images_correctly(self):
"""Should extract OutputImageGenerationCall objects from images"""
mock_response = Mock(spec=ModelResponse)
mock_response.id = "test_123"
mock_message = Mock(spec=Message)
mock_message.images = [
{"image_url": {"url": "data:image/png;base64,IMG1"}, "type": "image_url", "index": 0},
{"image_url": {"url": "data:image/jpeg;base64,IMG2"}, "type": "image_url", "index": 1},
]
mock_choice = Mock(spec=Choices)
mock_choice.message = mock_message
mock_choice.finish_reason = "stop"
result = LiteLLMCompletionResponsesConfig._extract_image_generation_output_items(
chat_completion_response=mock_response,
choice=mock_choice,
)
assert len(result) == 2
assert result[0].type == "image_generation_call"
assert result[0].result == "IMG1"
assert result[1].result == "IMG2"
assert result[0].id == "test_123_img_0"
assert result[1].id == "test_123_img_1"
assert result[0].status == "completed"
def test_returns_empty_for_no_images(self):
"""Should return empty list if no images"""
mock_response = Mock(spec=ModelResponse)
mock_message = Mock(spec=Message)
mock_message.images = []
mock_choice = Mock(spec=Choices)
mock_choice.message = mock_message
mock_choice.finish_reason = "stop"
result = LiteLLMCompletionResponsesConfig._extract_image_generation_output_items(
chat_completion_response=mock_response,
choice=mock_choice,
)
assert result == []
def test_maps_finish_reason_to_status(self):
"""Should correctly map finish_reason to status"""
mock_response = Mock(spec=ModelResponse)
mock_response.id = "test_finish"
mock_message = Mock(spec=Message)
mock_message.images = [
{"image_url": {"url": "data:image/png;base64,TEST"}, "type": "image_url", "index": 0}
]
mock_choice = Mock(spec=Choices)
mock_choice.message = mock_message
mock_choice.finish_reason = "length"
result = LiteLLMCompletionResponsesConfig._extract_image_generation_output_items(
chat_completion_response=mock_response,
choice=mock_choice,
)
assert result[0].status == "incomplete"
class TestExtractMessageOutputItemsIntegration:
"""Integration tests for _extract_message_output_items with images"""
def test_detects_images_and_creates_image_generation_call(self):
"""Should detect images in message and create image_generation_call output"""
mock_response = Mock(spec=ModelResponse)
mock_response.id = "integration_test_123"
mock_message = Mock(spec=Message)
mock_message.images = [
{
"image_url": {"url": "data:image/png;base64,INTEGRATION_TEST"},
"type": "image_url",
"index": 0,
}
]
mock_message.role = "assistant"
mock_message.content = "Here's your image!"
mock_choice = Mock(spec=Choices)
mock_choice.message = mock_message
mock_choice.finish_reason = "stop"
result = LiteLLMCompletionResponsesConfig._extract_message_output_items(
chat_completion_response=mock_response,
choices=[mock_choice],
)
# Should return image_generation_call, NOT regular message
assert len(result) == 1
assert isinstance(result[0], OutputImageGenerationCall)
assert result[0].type == "image_generation_call"
assert result[0].result == "INTEGRATION_TEST"
def test_creates_regular_message_when_no_images(self):
"""Should create regular GenericResponseOutputItem when no images"""
from litellm.types.responses.main import GenericResponseOutputItem
mock_response = Mock(spec=ModelResponse)
mock_response.id = "no_images_123"
mock_message = Mock(spec=Message)
# No images attribute or empty
mock_message.role = "assistant"
mock_message.content = "Just text, no images"
mock_choice = Mock(spec=Choices)
mock_choice.message = mock_message
mock_choice.finish_reason = "stop"
result = LiteLLMCompletionResponsesConfig._extract_message_output_items(
chat_completion_response=mock_response,
choices=[mock_choice],
)
assert len(result) == 1
assert isinstance(result[0], GenericResponseOutputItem)
assert result[0].type == "message"

View file

@ -324,6 +324,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@ -2185,6 +2186,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@ -2227,6 +2229,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@ -2336,6 +2339,7 @@
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
"license": "MIT",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@ -2757,6 +2761,7 @@
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
"license": "MIT",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@ -5824,6 +5829,7 @@
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@ -6617,6 +6623,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.48.tgz",
"integrity": "sha512-qboRCl6Ie70DQQG9hhNREz81jqC1cs9EVNcjQ1AU+jH6NFfSAhVVbrrY/+nSF+Bsk4AOwm9Qa61InvMCyV+H3w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"@types/scheduler": "*",
@ -6639,6 +6646,7 @@
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^18.0.0"
}
@ -6835,6 +6843,7 @@
"integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.47.0",
"@typescript-eslint/types": "8.47.0",
@ -7496,6 +7505,7 @@
"integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/utils": "3.2.4",
"fflate": "^0.8.2",
@ -7724,6 +7734,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -7813,6 +7824,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@ -8666,6 +8678,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754",
@ -8990,6 +9003,7 @@
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz",
"integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@chevrotain/cst-dts-gen": "11.0.3",
"@chevrotain/gast": "11.0.3",
@ -9718,6 +9732,7 @@
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
"license": "MIT",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@ -10080,6 +10095,7 @@
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz",
"integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10"
}
@ -10489,6 +10505,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@ -10663,6 +10680,7 @@
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
@ -11540,6 +11558,7 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@ -11725,6 +11744,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@ -14988,6 +15008,7 @@
"integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@acemir/cssom": "^0.9.23",
"@asamuzakjp/dom-selector": "^6.7.4",
@ -15142,12 +15163,12 @@
}
},
"node_modules/jws": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz",
"integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==",
"license": "MIT",
"dependencies": {
"jwa": "^1.4.1",
"jwa": "^1.4.2",
"safe-buffer": "^5.0.1"
}
},
@ -15991,9 +16012,9 @@
}
},
"node_modules/mdast-util-to-hast": {
"version": "13.2.0",
"resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz",
"integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==",
"version": "13.2.1",
"resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
"integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
@ -18115,6 +18136,7 @@
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"license": "MIT",
"peer": true,
"engines": {
"node": "*"
}
@ -19306,6 +19328,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@ -20318,6 +20341,7 @@
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
"license": "MIT",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@ -21812,6 +21836,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@ -21851,6 +21876,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@ -21908,6 +21934,7 @@
"resolved": "https://registry.npmjs.org/@docusaurus/react-loadable/-/react-loadable-6.0.0.tgz",
"integrity": "sha512-YMMxTUQV/QFSnbgrP3tjDzLHRg7vsbMn8e9HAa8o/1iXoiomo48b7sk/kkmWEuWNDPJVlKSJRB6Y2fHqdJk+SQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/react": "*"
},
@ -21973,6 +22000,7 @@
"resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz",
"integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.12.13",
"history": "^4.9.0",
@ -23010,8 +23038,7 @@
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/schema-dts/-/schema-dts-1.1.5.tgz",
"integrity": "sha512-RJr9EaCmsLzBX2NDiO5Z3ux2BVosNZN5jo0gWgsyKvxKIUL5R3swNvoorulAeL9kLB0iTSX7V6aokhla2m7xbg==",
"license": "Apache-2.0",
"peer": true
"license": "Apache-2.0"
},
"node_modules/schema-utils": {
"version": "4.3.3",
@ -23037,6 +23064,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@ -24268,6 +24296,7 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz",
"integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@ -24578,6 +24607,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -24790,7 +24820,8 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
"license": "0BSD",
"peer": true
},
"node_modules/type-check": {
"version": "0.4.0",
@ -24923,6 +24954,7 @@
"integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -25465,6 +25497,7 @@
"integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@ -25581,6 +25614,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -25594,6 +25628,7 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@ -25799,6 +25834,7 @@
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.103.0.tgz",
"integrity": "sha512-HU1JOuV1OavsZ+mfigY0j8d1TgQgbZ6M+J75zDkpEAwYeXjWSqrGJtgnPblJjd/mAyTNQ7ygw0MiKOn6etz8yw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8",

View file

@ -0,0 +1,41 @@
import { allEndUsersCall } from "@/components/networking";
import { useQuery } from "@tanstack/react-query";
import { createQueryKeys } from "../common/queryKeysFactory";
import { all_admin_roles } from "@/utils/roles";
const customersKeys = createQueryKeys("customers");
export interface Customer {
user_id: string;
alias?: string | null;
spend: number;
blocked: boolean;
allowed_model_region?: string | null;
default_model?: string | null;
budget_id?: string | null;
litellm_budget_table?: {
budget_id: string;
max_budget?: number | null;
soft_budget?: number | null;
max_parallel_requests?: number | null;
tpm_limit?: number | null;
rpm_limit?: number | null;
model_max_budget?: Record<string, unknown> | null;
budget_duration?: string | null;
budget_reset_at?: string | null;
created_at: string;
created_by: string;
updated_at: string;
updated_by: string;
} | null;
}
export type CustomersResponse = Customer[];
export const useCustomers = (accessToken: string | null, userRole: string | null) => {
return useQuery<CustomersResponse>({
queryKey: customersKeys.list({}),
queryFn: async () => await allEndUsersCall(accessToken!),
enabled: Boolean(accessToken) && all_admin_roles.includes(userRole || ""),
});
};

View file

@ -0,0 +1,82 @@
/* @vitest-environment jsdom */
import { renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import useAuthorized from "./useAuthorized";
const { replaceMock, clearTokenCookiesMock, getProxyBaseUrlMock } = vi.hoisted(() => ({
replaceMock: vi.fn(),
clearTokenCookiesMock: vi.fn(),
getProxyBaseUrlMock: vi.fn(() => "http://proxy.example"),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({
replace: replaceMock,
}),
}));
vi.mock("@/components/networking", () => ({
getProxyBaseUrl: getProxyBaseUrlMock,
}));
vi.mock("@/utils/cookieUtils", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/utils/cookieUtils")>();
return {
...actual,
clearTokenCookies: clearTokenCookiesMock,
};
});
const createJwt = (payload: Record<string, unknown>) => {
const base64Url = btoa(JSON.stringify(payload)).replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
return `eyJhbGciOiJub25lIn0.${base64Url}.signature`;
};
const clearCookie = () => {
document.cookie = "token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
};
describe("useAuthorized", () => {
afterEach(() => {
replaceMock.mockReset();
clearTokenCookiesMock.mockReset();
getProxyBaseUrlMock.mockClear();
clearCookie();
});
it("should decode the token and expose user details", () => {
const token = createJwt({
key: "api-key-123",
user_id: "user-1",
user_email: "user@example.com",
user_role: "app_admin",
premium_user: true,
disabled_non_admin_personal_key_creation: false,
login_method: "username_password",
});
document.cookie = `token=${token}; path=/;`;
const { result } = renderHook(() => useAuthorized());
expect(result.current.token).toBe(token);
expect(result.current.accessToken).toBe("api-key-123");
expect(result.current.userId).toBe("user-1");
expect(result.current.userEmail).toBe("user@example.com");
expect(result.current.userRole).toBe("Admin");
expect(result.current.premiumUser).toBe(true);
expect(result.current.disabledPersonalKeyCreation).toBe(false);
expect(result.current.showSSOBanner).toBe(true);
expect(replaceMock).not.toHaveBeenCalled();
});
it("should clear cookies and redirect on an invalid token", () => {
document.cookie = "token=invalid-token; path=/;";
const { result } = renderHook(() => useAuthorized());
expect(clearTokenCookiesMock).toHaveBeenCalled();
expect(replaceMock).toHaveBeenCalledWith("http://proxy.example/ui/login");
expect(result.current.accessToken).toBeNull();
expect(result.current.userRole).toBe("Undefined Role");
});
});

View file

@ -4,6 +4,7 @@ import { useEffect, useMemo } from "react";
import { useRouter } from "next/navigation";
import { jwtDecode } from "jwt-decode";
import { clearTokenCookies, getCookie } from "@/utils/cookieUtils";
import { getProxyBaseUrl } from "@/components/networking";
function formatUserRole(userRole: string) {
if (!userRole) {
@ -42,7 +43,7 @@ const useAuthorized = () => {
// Redirect after mount if missing/invalid token
useEffect(() => {
if (!token) {
router.replace("/sso/key/generate");
router.replace(`${getProxyBaseUrl()}/ui/login`);
}
}, [token, router]);
@ -54,7 +55,7 @@ const useAuthorized = () => {
} catch {
// Bad token in cookie — clear and bounce
clearTokenCookies();
router.replace("/sso/key/generate");
router.replace(`${getProxyBaseUrl()}/ui/login`);
return null;
}
}, [token, router]);

View file

@ -1,11 +1,11 @@
import React from "react";
import { Radio } from "antd";
import type { ExportScope } from "./types";
import type { ExportScope, EntityType } from "./types";
interface ExportTypeSelectorProps {
value: ExportScope;
onChange: (value: ExportScope) => void;
entityType: "tag" | "team" | "organization";
entityType: EntityType;
}
const ExportTypeSelector: React.FC<ExportTypeSelectorProps> = ({ value, onChange, entityType }) => {

View file

@ -7,7 +7,7 @@ import type { EntitySpendData } from "./types";
interface UsageExportHeaderProps {
dateValue: DateRangePickerValue;
entityType: "tag" | "team" | "organization";
entityType: "tag" | "team" | "organization" | "customer";
spendData: EntitySpendData;
// Optional filter props
showFilters?: boolean;

View file

@ -2,6 +2,7 @@ import type { DateRangePickerValue } from "@tremor/react";
export type ExportFormat = "csv" | "json";
export type ExportScope = "daily" | "daily_with_models";
export type EntityType = "tag" | "team" | "organization" | "customer";
export interface EntitySpendData {
results: any[];
@ -17,7 +18,7 @@ export interface EntitySpendData {
export interface EntityUsageExportModalProps {
isOpen: boolean;
onClose: () => void;
entityType: "tag" | "team" | "organization";
entityType: EntityType;
spendData: EntitySpendData;
dateRange: DateRangePickerValue;
selectedFilters: string[];

View file

@ -1,6 +1,6 @@
import { formatNumberWithCommas } from "@/utils/dataUtils";
import Papa from "papaparse";
import type { EntitySpendData, EntityBreakdown, ExportMetadata, ExportScope } from "./types";
import type { EntitySpendData, EntityBreakdown, ExportMetadata, ExportScope, EntityType } from "./types";
import type { DateRangePickerValue } from "@tremor/react";
export const getEntityBreakdown = (spendData: EntitySpendData): EntityBreakdown[] => {
@ -139,7 +139,7 @@ export const generateExportData = (
};
export const generateMetadata = (
entityType: "tag" | "team" | "organization",
entityType: EntityType,
dateRange: DateRangePickerValue,
selectedFilters: string[],
exportScope: ExportScope,
@ -166,7 +166,7 @@ export const handleExportCSV = (
spendData: EntitySpendData,
exportScope: ExportScope,
entityLabel: string,
entityType: "tag" | "team" | "organization",
entityType: EntityType,
): void => {
const data = generateExportData(spendData, exportScope, entityLabel);
const csv = Papa.unparse(data);
@ -186,7 +186,7 @@ export const handleExportJSON = (
spendData: EntitySpendData,
exportScope: ExportScope,
entityLabel: string,
entityType: "tag" | "team" | "organization",
entityType: EntityType,
dateRange: DateRangePickerValue,
selectedFilters: string[],
): void => {

View file

@ -42,7 +42,11 @@ const createQueryClient = () =>
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
staleTime: Infinity,
gcTime: Infinity,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
},
},
});
@ -140,7 +144,7 @@ describe("Add Model Tab", () => {
);
expect(await screen.findByRole("tab", { name: "Add Model" })).toBeInTheDocument();
});
}, 10000); // This test is flaky, adding a timeout until we find a better solution
it("should display both Add Model and Add Auto Router tabs", async () => {
const props = createTestProps();

View file

@ -1,7 +1,7 @@
import { useProviderFields } from "@/app/(dashboard)/hooks/providers/useProviderFields";
import { UploadOutlined } from "@ant-design/icons";
import { Text, TextInput } from "@tremor/react";
import { Button as Button2, Col, Form, Row, Select, Typography, Upload, UploadProps } from "antd";
import { Button as Button2, Col, Form, Input, Row, Select, Typography, Upload, UploadProps } from "antd";
import React from "react";
import { CredentialItem, ProviderCredentialFieldMetadata } from "../networking";
import { provider_map, Providers } from "../provider_info_helpers";
@ -18,7 +18,7 @@ interface ProviderCredentialField {
placeholder?: string;
tooltip?: string;
required?: boolean;
type?: "text" | "password" | "select" | "upload";
type?: "text" | "password" | "select" | "upload" | "textarea";
options?: string[];
defaultValue?: string;
}
@ -36,7 +36,9 @@ const mapFieldMetadataToUiField = (field: ProviderCredentialFieldMetadata): Prov
? "select"
: field.field_type === "upload"
? "upload"
: "text";
: field.field_type === "textarea"
? "textarea"
: "text";
return {
key: field.key,
@ -247,6 +249,13 @@ const ProviderSpecificFields: React.FC<ProviderSpecificFieldsProps> = ({ selecte
>
<Button2 icon={<UploadOutlined />}>Click to Upload</Button2>
</Upload>
) : field.type === "textarea" ? (
<Input.TextArea
placeholder={field.placeholder}
defaultValue={field.defaultValue}
rows={6}
style={{ fontFamily: "monospace", fontSize: "12px" }}
/>
) : (
<TextInput
placeholder={field.placeholder}

View file

@ -18,6 +18,7 @@ vi.mock("./networking", () => ({
tagDailyActivityCall: vi.fn(),
teamDailyActivityCall: vi.fn(),
organizationDailyActivityCall: vi.fn(),
customerDailyActivityCall: vi.fn(),
}));
// Mock the child components to simplify testing
@ -42,6 +43,7 @@ describe("EntityUsage", () => {
const mockTagDailyActivityCall = vi.mocked(networking.tagDailyActivityCall);
const mockTeamDailyActivityCall = vi.mocked(networking.teamDailyActivityCall);
const mockOrganizationDailyActivityCall = vi.mocked(networking.organizationDailyActivityCall);
const mockCustomerDailyActivityCall = vi.mocked(networking.customerDailyActivityCall);
const mockSpendData = {
results: [
@ -128,9 +130,11 @@ describe("EntityUsage", () => {
mockTagDailyActivityCall.mockClear();
mockTeamDailyActivityCall.mockClear();
mockOrganizationDailyActivityCall.mockClear();
mockCustomerDailyActivityCall.mockClear();
mockTagDailyActivityCall.mockResolvedValue(mockSpendData);
mockTeamDailyActivityCall.mockResolvedValue(mockSpendData);
mockOrganizationDailyActivityCall.mockResolvedValue(mockSpendData);
mockCustomerDailyActivityCall.mockResolvedValue(mockSpendData);
});
it("should render with tag entity type and display spend metrics", async () => {
@ -182,6 +186,21 @@ describe("EntityUsage", () => {
});
});
it("should render with customer entity type and call customer API", async () => {
render(<EntityUsage {...defaultProps} entityType="customer" />);
await waitFor(() => {
expect(mockCustomerDailyActivityCall).toHaveBeenCalled();
});
expect(screen.getByText("Customer Spend Overview")).toBeInTheDocument();
await waitFor(() => {
const spendElements = screen.getAllByText("$100.50");
expect(spendElements.length).toBeGreaterThan(0);
});
});
it("should switch between tabs", async () => {
render(<EntityUsage {...defaultProps} />);

View file

@ -23,12 +23,18 @@ import {
} from "@tremor/react";
import { ActivityMetrics, processActivityData } from "./activity_metrics";
import { DailyData, BreakdownMetrics, KeyMetricWithMetadata, EntityMetricWithMetadata, TagUsage } from "./usage/types";
import { organizationDailyActivityCall, tagDailyActivityCall, teamDailyActivityCall } from "./networking";
import {
organizationDailyActivityCall,
tagDailyActivityCall,
teamDailyActivityCall,
customerDailyActivityCall,
} from "./networking";
import TopKeyView from "./top_key_view";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { valueFormatterSpend } from "./usage/utils/value_formatters";
import { getProviderLogoAndName } from "./provider_info_helpers";
import { UsageExportHeader } from "./EntityUsageExport";
import type { EntityType } from "./EntityUsageExport/types";
import TopModelView from "./top_model_view";
interface EntityMetrics {
@ -68,7 +74,7 @@ export interface EntityList {
interface EntityUsageProps {
accessToken: string | null;
entityType: "tag" | "team" | "organization";
entityType: EntityType;
entityId?: string | null;
userID: string | null;
userRole: string | null;
@ -135,6 +141,15 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
selectedTags.length > 0 ? selectedTags : null,
);
setSpendData(data);
} else if (entityType === "customer") {
const data = await customerDailyActivityCall(
accessToken,
startTime,
endTime,
1,
selectedTags.length > 0 ? selectedTags : null,
);
setSpendData(data);
} else {
throw new Error("Invalid entity type");
}

View file

@ -182,7 +182,7 @@ export interface ProviderCredentialFieldMetadata {
placeholder?: string | null;
tooltip?: string | null;
required?: boolean;
field_type?: "text" | "password" | "select" | "upload";
field_type?: "text" | "password" | "select" | "upload" | "textarea";
options?: string[] | null;
default_value?: string | null;
}
@ -1736,6 +1736,25 @@ export const organizationDailyActivityCall = async (
});
};
export const customerDailyActivityCall = async (
accessToken: string,
startTime: Date,
endTime: Date,
page: number = 1,
customerIds: string[] | null = null,
) => {
return fetchDailyActivity({
accessToken,
endpoint: "/customer/daily/activity",
startTime,
endTime,
page,
extraQueryParams: {
end_user_ids: customerIds,
},
});
};
export const getTotalSpendCall = async (accessToken: string) => {
/**
* Get all models on proxy
@ -2511,7 +2530,7 @@ export const allEndUsersCall = async (accessToken: string) => {
console.log(data);
return data;
} catch (error) {
console.error("Failed to create key:", error);
console.error("Failed to fetch end users:", error);
throw error;
}
};

View file

@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest";
import NewUsagePage from "./new_usage";
import type { Organization } from "./networking";
import * as networking from "./networking";
import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers";
// Polyfill ResizeObserver for test environment
beforeAll(() => {
@ -53,9 +54,14 @@ vi.mock("./EntityUsageExport", () => ({
default: () => <div>Entity Usage Export Modal</div>,
}));
vi.mock("@/app/(dashboard)/hooks/customers/useCustomers", () => ({
useCustomers: vi.fn(),
}));
describe("NewUsage", () => {
const mockUserDailyActivityAggregatedCall = vi.mocked(networking.userDailyActivityAggregatedCall);
const mockTagListCall = vi.mocked(networking.tagListCall);
const mockUseCustomers = vi.mocked(useCustomers);
const mockSpendData = {
results: [
@ -174,6 +180,19 @@ describe("NewUsage", () => {
},
];
const mockCustomers = [
{
user_id: "customer-123",
alias: "Test Customer",
spend: 0,
blocked: false,
allowed_model_region: null,
default_model: null,
budget_id: null,
litellm_budget_table: null,
},
];
const defaultProps = {
accessToken: "test-token",
userRole: "Admin",
@ -205,6 +224,11 @@ describe("NewUsage", () => {
mockTagListCall.mockClear();
mockUserDailyActivityAggregatedCall.mockResolvedValue(mockSpendData);
mockTagListCall.mockResolvedValue({});
mockUseCustomers.mockReturnValue({
data: [],
isLoading: false,
error: null,
} as any);
});
it("should render and fetch usage data on mount", async () => {
@ -289,4 +313,26 @@ describe("NewUsage", () => {
expect(entityUsageElements.length).toBeGreaterThan(0);
});
});
it("should show customer usage tab for admins", async () => {
mockUseCustomers.mockReturnValue({
data: mockCustomers,
isLoading: false,
error: null,
} as any);
const { getByText, getAllByText } = render(<NewUsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
const customerTab = getByText("Customer Usage");
fireEvent.click(customerTab);
await waitFor(() => {
const entityUsageElements = getAllByText("Entity Usage");
expect(entityUsageElements.length).toBeGreaterThan(0);
});
});
});

View file

@ -27,9 +27,10 @@ import {
Text,
Title,
} from "@tremor/react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Alert } from "antd";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { Button } from "@tremor/react";
import { all_admin_roles } from "../utils/roles";
@ -86,6 +87,7 @@ const NewUsagePage: React.FC<NewUsagePageProps> = ({
});
const [allTags, setAllTags] = useState<EntityList[]>([]);
const { data: customers = [] } = useCustomers(accessToken, userRole);
const [modelViewType, setModelViewType] = useState<"groups" | "individual">("groups");
const [isCloudZeroModalOpen, setIsCloudZeroModalOpen] = useState(false);
const [isGlobalExportModalOpen, setIsGlobalExportModalOpen] = useState(false);
@ -430,6 +432,7 @@ const NewUsagePage: React.FC<NewUsagePageProps> = ({
<Tab>Your Organization Usage</Tab>
)}
<Tab>Team Usage</Tab>
{all_admin_roles.includes(userRole || "") ? <Tab>Customer Usage</Tab> : <></>}
{all_admin_roles.includes(userRole || "") ? <Tab>Tag Usage</Tab> : <></>}
{all_admin_roles.includes(userRole || "") ? <Tab>User Agent Activity</Tab> : <></>}
</TabList>
@ -798,6 +801,23 @@ const NewUsagePage: React.FC<NewUsagePageProps> = ({
/>
</TabPanel>
{/* Customer Usage Panel */}
<TabPanel>
<EntityUsage
accessToken={accessToken}
entityType="customer"
userID={userID}
userRole={userRole}
entityList={
customers?.map((customer) => ({
label: customer.alias || customer.user_id,
value: customer.user_id,
})) || null
}
premiumUser={premiumUser}
dateValue={dateValue}
/>
</TabPanel>
{/* Tag Usage Panel */}
<TabPanel>
<EntityUsage

View file

@ -2,6 +2,7 @@ import { render, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import CompareUI from "./CompareUI";
import { makeOpenAIChatCompletionRequest } from "../llm_calls/chat_completion";
vi.mock("../llm_calls/fetch_models", () => ({
fetchAvailableModels: vi.fn().mockResolvedValue([{ model_group: "gpt-4" }, { model_group: "gpt-3.5-turbo" }]),
@ -11,6 +12,34 @@ vi.mock("../llm_calls/chat_completion", () => ({
makeOpenAIChatCompletionRequest: vi.fn().mockResolvedValue(undefined),
}));
let capturedOnImageUpload: ((file: File) => false) | null = null;
vi.mock("../chat_ui/ChatImageUpload", () => ({
default: ({ onImageUpload }: { onImageUpload: (file: File) => false }) => {
capturedOnImageUpload = onImageUpload;
return (
<div data-testid="chat-image-upload">
<button data-testid="trigger-upload">Upload</button>
</div>
);
},
}));
vi.mock("../chat_ui/ChatImageUtils", () => ({
createChatMultimodalMessage: vi.fn().mockResolvedValue({
role: "user",
content: [
{ type: "text", text: "test message" },
{ type: "image_url", image_url: { url: "data:image/png;base64,test" } },
],
}),
createChatDisplayMessage: vi.fn().mockReturnValue({
role: "user",
content: "test message [Image attached]",
imagePreviewUrl: "blob:test-url",
}),
}));
vi.mock("./components/ComparisonPanel", () => ({
ComparisonPanel: ({ comparison, onRemove }: { comparison: any; onRemove: () => void }) => (
<div data-testid={`comparison-panel-${comparison.id}`}>
@ -22,8 +51,9 @@ vi.mock("./components/ComparisonPanel", () => ({
}));
vi.mock("./components/MessageInput", () => ({
MessageInput: ({ value, onChange, onSend, disabled }: any) => (
MessageInput: ({ value, onChange, onSend, disabled, hasAttachment, uploadComponent }: any) => (
<div data-testid="message-input">
{uploadComponent && <div data-testid="upload-component">{uploadComponent}</div>}
<textarea
data-testid="message-textarea"
value={value}
@ -33,6 +63,7 @@ vi.mock("./components/MessageInput", () => ({
<button data-testid="send-button" onClick={onSend} disabled={disabled}>
Send
</button>
{hasAttachment && <div data-testid="has-attachment">Attachment</div>}
</div>
),
}));
@ -51,6 +82,10 @@ beforeEach(() => {
dispatchEvent: () => false,
}),
});
global.URL.createObjectURL = vi.fn().mockReturnValue("blob:test-url");
global.URL.revokeObjectURL = vi.fn();
capturedOnImageUpload = null;
vi.clearAllMocks();
});
describe("CompareUI", () => {
@ -88,4 +123,36 @@ describe("CompareUI", () => {
expect(getByTestId("comparison-panel-1")).toBeInTheDocument();
expect(getByTestId("comparison-panel-2")).toBeInTheDocument();
});
it("should handle image upload and send message with attachment", async () => {
const user = userEvent.setup();
const { getByTestId, queryByTestId } = render(
<CompareUI accessToken="test-token" disabledPersonalKeyCreation={false} />,
);
const file = new File(["test content"], "test-image.png", { type: "image/png" });
await waitFor(() => {
expect(capturedOnImageUpload).not.toBeNull();
});
if (capturedOnImageUpload) {
capturedOnImageUpload(file);
}
await waitFor(() => {
expect(queryByTestId("has-attachment")).toBeInTheDocument();
});
const textarea = getByTestId("message-textarea");
await user.type(textarea, "Describe this image");
const sendButton = getByTestId("send-button");
expect(sendButton).not.toBeDisabled();
await user.click(sendButton);
await waitFor(() => {
expect(makeOpenAIChatCompletionRequest).toHaveBeenCalled();
});
});
});

View file

@ -1,14 +1,16 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import { v4 as uuidv4 } from "uuid";
import { Select, Input, Tooltip, Button } from "antd";
import { ClearOutlined, PlusOutlined } from "@ant-design/icons";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { fetchAvailableModels } from "../llm_calls/fetch_models";
import { makeOpenAIChatCompletionRequest } from "../llm_calls/chat_completion";
import { ClearOutlined, DeleteOutlined, FilePdfOutlined, PlusOutlined } from "@ant-design/icons";
import { Button, Input, Select, Tooltip } from "antd";
import { useEffect, useMemo, useState } from "react";
import { v4 as uuidv4 } from "uuid";
import ChatImageUpload from "../chat_ui/ChatImageUpload";
import { createChatDisplayMessage, createChatMultimodalMessage } from "../chat_ui/ChatImageUtils";
import type { TokenUsage } from "../chat_ui/ResponseMetrics";
import type { MessageType, VectorStoreSearchResponse } from "../chat_ui/types";
import { makeOpenAIChatCompletionRequest } from "../llm_calls/chat_completion";
import { fetchAvailableModels } from "../llm_calls/fetch_models";
import { ComparisonPanel } from "./components/ComparisonPanel";
import { MessageInput } from "./components/MessageInput";
export interface ComparisonInstance {
@ -71,6 +73,8 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }:
const [modelOptions, setModelOptions] = useState<string[]>([]);
const [isLoadingModels, setIsLoadingModels] = useState(false);
const [inputValue, setInputValue] = useState("");
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const [uploadedFilePreviewUrl, setUploadedFilePreviewUrl] = useState<string | null>(null);
const [apiKeySource, setApiKeySource] = useState<"session" | "custom">(
disabledPersonalKeyCreation ? "custom" : "session",
);
@ -82,6 +86,13 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }:
}, 300);
return () => clearTimeout(timer);
}, [customApiKey]);
useEffect(() => {
return () => {
if (uploadedFilePreviewUrl) {
URL.revokeObjectURL(uploadedFilePreviewUrl);
}
};
}, [uploadedFilePreviewUrl]);
const effectiveApiKey = useMemo(
() => (apiKeySource === "session" ? accessToken || "" : debouncedCustomApiKey.trim()),
[apiKeySource, accessToken, debouncedCustomApiKey],
@ -215,6 +226,21 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }:
);
});
};
const handleFileUpload = (file: File): false => {
if (uploadedFilePreviewUrl) {
URL.revokeObjectURL(uploadedFilePreviewUrl);
}
setUploadedFile(file);
setUploadedFilePreviewUrl(URL.createObjectURL(file));
return false;
};
const handleRemoveFile = () => {
if (uploadedFilePreviewUrl) {
URL.revokeObjectURL(uploadedFilePreviewUrl);
}
setUploadedFile(null);
setUploadedFilePreviewUrl(null);
};
const clearAllChats = () => {
setComparisons((prev) =>
prev.map((comparison) => ({
@ -225,6 +251,7 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }:
})),
);
setInputValue("");
handleRemoveFile();
};
const appendAssistantChunk = (comparisonId: string, chunk: string, model?: string) => {
if (!chunk) {
@ -389,9 +416,10 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }:
);
};
const canUseSessionKey = Boolean(accessToken);
const handleSendMessage = (input: string) => {
const handleSendMessage = async (input: string) => {
const trimmed = input.trim();
if (!trimmed) {
const hasAttachment = Boolean(uploadedFile);
if (!trimmed && !hasAttachment) {
return;
}
if (!effectiveApiKey) {
@ -406,6 +434,17 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }:
NotificationsManager.fromBackend("Select a model before sending a message.");
return;
}
const apiUserMessage = hasAttachment
? await createChatMultimodalMessage(trimmed, uploadedFile as File)
: { role: "user", content: trimmed };
const displayUserMessage = createChatDisplayMessage(
trimmed,
hasAttachment,
uploadedFilePreviewUrl || undefined,
uploadedFile?.name,
);
const preparedTargets = new Map<
string,
{
@ -417,15 +456,19 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }:
guardrails: string[];
temperature: number;
maxTokens: number;
messages: MessageType[];
displayMessages: MessageType[];
apiChatHistory: Array<{ role: string; content: string | any[] }>;
}
>();
targetComparisons.forEach((comparison) => {
const traceId = comparison.traceId ?? uuidv4();
const userMessage: MessageType = {
role: "user",
content: trimmed,
};
const apiChatHistory = [
...comparison.messages.map(({ role, content }) => ({
role,
content: Array.isArray(content) ? content : typeof content === "string" ? content : "",
})),
apiUserMessage,
];
preparedTargets.set(comparison.id, {
id: comparison.id,
model: comparison.model,
@ -435,7 +478,8 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }:
guardrails: comparison.guardrails,
temperature: comparison.temperature,
maxTokens: comparison.maxTokens,
messages: [...comparison.messages, userMessage],
displayMessages: [...comparison.messages, displayUserMessage],
apiChatHistory,
});
});
if (preparedTargets.size === 0) {
@ -450,23 +494,22 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }:
return {
...comparison,
traceId: prepared.traceId,
messages: prepared.messages,
messages: prepared.displayMessages,
isLoading: true,
};
}),
);
setInputValue("");
handleRemoveFile();
preparedTargets.forEach((prepared) => {
const apiChatHistory = prepared.messages.map(({ role, content }) => ({
role,
content: typeof content === "string" ? content : "",
}));
const tags = prepared.tags.length > 0 ? prepared.tags : undefined;
const vectorStoreIds = prepared.vectorStores.length > 0 ? prepared.vectorStores : undefined;
const guardrails = prepared.guardrails.length > 0 ? prepared.guardrails : undefined;
const comparison = comparisons.find((c) => c.id === prepared.id);
const useAdvancedParams = comparison?.useAdvancedParams ?? false;
makeOpenAIChatCompletionRequest(
apiChatHistory,
prepared.apiChatHistory,
(chunk, model) => appendAssistantChunk(prepared.id, chunk, model),
prepared.model,
effectiveApiKey,
@ -536,18 +579,19 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }:
setInputValue(value);
};
const handleSubmit = () => {
handleSendMessage(inputValue);
setInputValue("");
void handleSendMessage(inputValue);
};
const handleFollowUpSelect = (question: string) => {
setInputValue(question);
};
const hasMessages = comparisons.some((comparison) => comparison.messages.length > 0);
const isAnyComparisonLoading = comparisons.some((comparison) => comparison.isLoading);
const showSuggestedPrompts = !hasMessages && !isAnyComparisonLoading;
const hasAttachment = Boolean(uploadedFile);
const isUploadedFilePdf = Boolean(uploadedFile?.name.toLowerCase().endsWith(".pdf"));
const showSuggestedPrompts = !hasMessages && !isAnyComparisonLoading && !hasAttachment;
return (
<div className="w-full h-full p-4 bg-white">
<div className="rounded-2xl border border-gray-200 bg-white shadow-sm min-h-[calc(100vh-140px)] flex flex-col">
<div className="rounded-2xl border border-gray-200 bg-white shadow-sm min-h-[calc(100vh-160px)] flex flex-col">
<div className="border-b px-4 py-2">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
@ -620,7 +664,9 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }:
<div className="w-full max-w-3xl px-4">
<div className="border border-gray-200 shadow-lg rounded-xl bg-white p-4">
<div className="flex items-center justify-between gap-4 mb-3 min-h-8">
{showSuggestedPrompts ? (
{hasAttachment ? (
<span className="text-sm text-gray-500">Attachment ready to send</span>
) : showSuggestedPrompts ? (
<div className="flex items-center gap-2 overflow-x-auto">
{SUGGESTED_PROMPTS.map((prompt) => (
<button
@ -633,7 +679,7 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }:
</button>
))}
</div>
) : haveAllResponses ? (
) : haveAllResponses && !hasAttachment ? (
<div className="flex items-center gap-2 overflow-x-auto">
{GENERIC_FOLLOW_UPS.map((question) => (
<button
@ -655,11 +701,49 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }:
<span className="text-sm text-gray-500">Send a prompt to compare models</span>
)}
</div>
{uploadedFile && (
<div className="mb-3">
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg border border-gray-200">
<div className="relative inline-block">
{isUploadedFilePdf ? (
<div className="w-10 h-10 rounded-md bg-red-500 flex items-center justify-center">
<FilePdfOutlined style={{ fontSize: "16px", color: "white" }} />
</div>
) : (
<img
src={uploadedFilePreviewUrl || ""}
alt="Upload preview"
className="w-10 h-10 rounded-md border border-gray-200 object-cover"
/>
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 truncate">{uploadedFile.name}</div>
<div className="text-xs text-gray-500">{isUploadedFilePdf ? "PDF" : "Image"}</div>
</div>
<button
className="flex items-center justify-center w-6 h-6 text-gray-400 hover:text-gray-600 hover:bg-gray-200 rounded-full transition-colors"
onClick={handleRemoveFile}
>
<DeleteOutlined style={{ fontSize: "12px" }} />
</button>
</div>
</div>
)}
<MessageInput
value={inputValue}
onChange={handleInputChange}
onSend={handleSubmit}
disabled={comparisons.length === 0 || comparisons.every((comparison) => comparison.isLoading)}
hasAttachment={hasAttachment}
uploadComponent={
<ChatImageUpload
chatUploadedImage={uploadedFile}
chatImagePreviewUrl={uploadedFilePreviewUrl}
onImageUpload={handleFileUpload}
onRemoveImage={handleRemoveFile}
/>
}
/>
</div>
</div>

View file

@ -17,6 +17,15 @@ vi.mock("../../chat_ui/SearchResultsDisplay", () => ({
SearchResultsDisplay: () => <div data-testid="search-results">SearchResultsDisplay</div>,
}));
vi.mock("../../chat_ui/ChatImageRenderer", () => ({
default: ({ message }: { message: any }) =>
message.imagePreviewUrl ? (
<div data-testid="chat-image-renderer">
<img src={message.imagePreviewUrl} alt="User uploaded image" />
</div>
) : null,
}));
describe("MessageDisplay", () => {
it("should render", () => {
const messages: MessageType[] = [
@ -63,4 +72,24 @@ describe("MessageDisplay", () => {
expect(getByText("2+2 equals 4")).toBeInTheDocument();
expect(getByTestId("response-metrics")).toBeInTheDocument();
});
it("should display image attachment in user message", () => {
const messages: MessageType[] = [
{
role: "user",
content: "What is in this image? [Image attached]",
imagePreviewUrl: "blob:test-image-url",
},
{
role: "assistant",
content: "This is a test image",
model: "gpt-4",
},
];
const { getByTestId, getByText } = render(<MessageDisplay messages={messages} isLoading={false} />);
expect(getByText("What is in this image? [Image attached]")).toBeInTheDocument();
expect(getByTestId("chat-image-renderer")).toBeInTheDocument();
const image = getByTestId("chat-image-renderer").querySelector("img");
expect(image).toHaveAttribute("src", "blob:test-image-url");
});
});

View file

@ -1,8 +1,9 @@
import { Bot, Loader2, UserRound } from "lucide-react";
import React from "react";
import ReactMarkdown from "react-markdown";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { coy } from "react-syntax-highlighter/dist/esm/styles/prism";
import { Bot, Loader2, UserRound } from "lucide-react";
import ChatImageRenderer from "../../chat_ui/ChatImageRenderer";
import ReasoningContent from "../../chat_ui/ReasoningContent";
import ResponseMetrics from "../../chat_ui/ResponseMetrics";
import { SearchResultsDisplay } from "../../chat_ui/SearchResultsDisplay";
@ -56,6 +57,7 @@ export function MessageDisplay({ messages, isLoading }: MessageDisplayProps) {
hyphens: "auto",
}}
>
<ChatImageRenderer message={message} />
<ReactMarkdown
components={{
code({

View file

@ -21,4 +21,16 @@ describe("MessageInput", () => {
expect(button).toBeDisabled();
});
it("should enable send button when hasAttachment is true even with empty value", () => {
const onChange = vi.fn();
const onSend = vi.fn();
const uploadComponent = <div data-testid="upload-component">Upload</div>;
const { container, getByTestId } = render(
<MessageInput value="" onChange={onChange} onSend={onSend} hasAttachment={true} uploadComponent={uploadComponent} />,
);
const button = container.querySelector("button") as HTMLButtonElement;
expect(getByTestId("upload-component")).toBeInTheDocument();
expect(button).not.toBeDisabled();
});
});

Some files were not shown because too many files have changed in this diff Show more