Merge remote-tracking branch 'origin/main' into ci-fix-april9-fixes

This commit is contained in:
Ishaan Jaffer 2026-04-11 12:03:24 -07:00
commit b684cd4ebb
No known key found for this signature in database
51 changed files with 2295 additions and 1333 deletions

View file

@ -32,6 +32,13 @@ If you're seeing a delay in your PR being merged, ping the LiteLLM Team on [Slac
- [ ] **Merge / cherry-pick CI run**
Links:
## Screenshots / Proof of Fix
<!-- Include screenshots, screen recordings, or log output demonstrating that your changes work as expected.
For bug fixes: show reproduction before the fix and passing behavior after.
For new features: show the feature working end-to-end.
For UI changes: include before/after screenshots. -->
## Type
<!-- Select the type of Pull Request -->

View file

@ -71,8 +71,16 @@ WORKDIR /app
RUN sed -i 's/\r$//' docker/entrypoint.sh && chmod +x docker/entrypoint.sh
RUN sed -i 's/\r$//' docker/prod_entrypoint.sh && chmod +x docker/prod_entrypoint.sh
# Run as non-root user
RUN groupadd --gid 1000 appuser && useradd --uid 1000 --gid 1000 --no-create-home appuser \
&& chown -R appuser:appuser /app
USER appuser
# Expose the necessary port
EXPOSE 4000/tcp
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:4000/health')"]
# Override the CMD instruction with your desired command and arguments
CMD ["--port", "4000", "--config", "config.yaml", "--detailed_debug"]

View file

@ -13,12 +13,12 @@ RUN pip install --no-cache-dir -r requirements.txt
RUN chmod +x /app/health_check_client.py
# Run as non-root user
RUN adduser --disabled-password --gecos "" --uid 1001 healthcheck
USER healthcheck
RUN groupadd --gid 1000 appuser && useradd --uid 1000 --gid 1000 --no-create-home appuser
USER appuser
# Health check
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD python /app/health_check_client.py --help || exit 1
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD ["python", "/app/health_check_client.py", "--help"]
# Set entrypoint
ENTRYPOINT ["python", "/app/health_check_client.py"]

View file

@ -4,6 +4,7 @@ title: "April Townhall: Security + Product Roadmap"
date: 2026-04-02T07:30:00
authors:
- krrish
- ishaan-alt
description: "Join the LiteLLM April townhall on Friday, 10 April at 7:30 AM to learn about LiteLLM's security and product roadmap."
tags: [announcement, townhall]
hide_table_of_contents: true

View file

@ -0,0 +1,162 @@
---
slug: april-townhall-updates
title: "April Townhall Updates: CI/CD v2, Stability, and Product Roadmap"
date: 2026-04-10T12:00:00
authors:
- krrish
- ishaan-alt
description: "A recap of the April LiteLLM town hall covering CI/CD v2, product stability work, and the near-term roadmap."
tags: [townhall, security, reliability, product]
hide_table_of_contents: false
---
import Image from '@theme/IdealImage';
Thank you to everyone who joined our April town hall.
We used the session to share our CI/CD v2 improvements, product stability work, and what we are prioritizing next across reliability and product roadmap.
{/* truncate */}
## CI/CD v2 improvements
Our CI/CD v2 work is centered around four goals:
1. **Limit** what each package can access
2. **Reduce** the number of sensitive environment variables
3. **Avoid** compromised packages
4. **Reduce the risk of** release tampering
#### New architecture: isolated environments
We have begun moving to isolated environments for distinct CI/CD stages to reduce the chance that a single compromised step can inherit broad access across the entire pipeline.
<Image
img={require('../../img/april_townhall_isolated_environments.png')}
style={{width: '900px', height: 'auto', display: 'block'}}
/>
#### Current rollout status
These changes are deployed in our current release workflow. [See here](https://github.com/BerriAI/litellm/tags)
#### Independently verify releases
A key part of CI/CD v2 is supporting independent verification of release artifacts using our published verification process, while reducing reliance on any single credential or release path.
[**Learn more about how to verify releases**](https://docs.litellm.ai/docs/proxy/docker_image_security)
<Image
img={require('../../img/verify_releases.png')}
style={{width: '900px', height: 'auto', display: 'block'}}
/>
## Stability improvements
### SDLC improvements
This month, we're focusing on process stability improvements around:
- Improving main-branch stability
- Mapping UI QA to built Docker images for 1:1 environment parity
- Consistent release tags across PyPI and Docker
- Fixing release notes publication
#### Improving main-branch stability
We're introducing a staging-gated flow:
<Image
img={require('../../img/stable_main.png')}
style={{width: '900px', height: 'auto', display: 'block'}}
/>
- Only an internal staging branch can push to `main`.
- PRs to that staging branch must pass CircleCI LLM API testing.
- Collision handling happens on staging, which is designed to reduce unstable changes reaching `main`.
#### UI QA in Docker environment
Moving forward, all UI QA will be performed in the built Docker image that users run.
Previously, some UI QA paths were run in local environments that did not fully replicate Docker runtime conditions.
That contributed to release-specific issues, including MCP registration problems in `v1.82.3`.
#### Consistent release tags
Today we publish releases for multiple scenarios:
- Dev (Built of a PR for a customer-specific scenario)
- Nightly (Passes all CI/CD checks)
- Release Candidate (Passes all CI/CD checks + manual UI QA)
- Stable (intended to pass all CI/CD checks + manual UI QA + 7 days of production testing)
We are targeting a consistent naming convention across PyPI and Docker by the end of April.
#### Release notes
CI/CD v2 changes moved release notes to a manual path. This is a temporary solution while we investigate a better automated workflow. We are targeting a more consistent process by the end of April.
### Product stability improvements
#### Stable Prisma migrations
Today, we have observed several migration failure classes:
- Migration not applied
- Migration marked applied but incomplete
- Migration not applied due to non-root image issues
We're prioritizing this work this month and have assigned an engineering owner to the effort. Our target is to resolve these error classes by the end of April.
#### UI type safety
Another area of focus is improving the stability of the UI. Today, one cause of errors is that the UI maintains its own assumptions about backend API types. This can lead to issues when backend responses differ from UI assumptions.
We aim to move to having the UI and Backend be in sync with each other, and are exploring OpenAPI-driven mapping to achieve this.
## Product roadmap
### Our Assumptions
Over the next few years, we expect:
- Companies will give employees more AI tools.
- More AI agents will move into production workflows across HR, finance, support, and operations.
### Our Inferences
#### Near-term
- AI spend will increase.
- Uptime and latency will become even more important.
- More AI resources (skills, CLIs, and related assets) will require governance.
- Agent and MCP usage patterns will require deeper controls.
- Broader developer adoption will increase the need for simpler, more discoverable tooling.
#### Long-term
- We expect many organizations to treat agent auditability (how decisions were made across LLM + MCP + sub-agent inputs/outputs) as a compliance expectation.
- Permission management will get more complex as user-agent interaction chains deepen.
Roadmap timelines in this post are targets and may evolve based on validation and user feedback.
## April investments
### Reliability
- Increase uptime for 10k+ RPS scenarios.
- Investigate latency overhead for long-running Claude Code requests.
### Feature reliability
- Polish MCP authentication.
- Better understand how teams are using agents through LiteLLM.
### Governance
- Launch Skills as a first-class citizen in LiteLLM.
## Q&A
Thank you again for all the questions and direct feedback. We will keep sharing concrete progress updates as these efforts ship.
## Hiring
We are actively hiring across several roles, please apply [here](https://jobs.ashbyhq.com/litellm) if you're interested!

View file

@ -24,7 +24,7 @@ ishaan:
# Alias for typo in name
ishaan-alt:
name: Ishaan Jaff
name: Ishaan Jaffer
title: CTO, LiteLLM
url: https://www.linkedin.com/in/reffajnaahsi/
image_url: https://pbs.twimg.com/profile_images/1613813310264340481/lz54oEiB_400x400.jpg

View file

@ -602,6 +602,8 @@ router_settings:
| MCP_OAUTH2_TOKEN_CACHE_MAX_SIZE | Maximum number of entries in MCP OAuth2 token cache. Default is 200
| MCP_OAUTH2_TOKEN_CACHE_MIN_TTL | Minimum TTL in seconds for MCP OAuth2 token cache. Default is 10
| MCP_OAUTH2_TOKEN_EXPIRY_BUFFER_SECONDS | Seconds to subtract from token expiry when computing cache TTL. Default is 60
| MCP_PER_USER_TOKEN_DEFAULT_TTL | Default TTL in seconds for per-user MCP OAuth tokens stored in Redis. Default is 43200 (12 hours)
| MCP_PER_USER_TOKEN_EXPIRY_BUFFER_SECONDS | Seconds to subtract from per-user MCP OAuth token expiry when computing Redis TTL. Default is 60
| DEFAULT_MOCK_RESPONSE_COMPLETION_TOKEN_COUNT | Default token count for mock response completions. Default is 20
| DEFAULT_MOCK_RESPONSE_PROMPT_TOKEN_COUNT | Default token count for mock response prompts. Default is 10
| DEFAULT_MODEL_CREATED_AT_TIME | Default creation timestamp for models. Default is 1677610602

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View file

@ -7,7 +7,8 @@ from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union
import yaml
from jinja2 import DictLoader, Environment, select_autoescape
from jinja2 import DictLoader, select_autoescape
from jinja2.sandbox import ImmutableSandboxedEnvironment
class PromptTemplate:
@ -59,7 +60,10 @@ class PromptManager:
self.prompt_directory = Path(prompt_directory) if prompt_directory else None
self.prompts: Dict[str, PromptTemplate] = {}
self.prompt_file = prompt_file
self.jinja_env = Environment(
# Sandboxed env: templates can come from user input via /prompts/test,
# so we must block access to unsafe Python attributes and mutation of
# caller-supplied mutables.
self.jinja_env = ImmutableSandboxedEnvironment(
loader=DictLoader({}),
autoescape=select_autoescape(["html", "xml"]),
# Use Handlebars-style delimiters to match Dotprompt spec

View file

@ -538,9 +538,9 @@ class AmazonAnthropicClaudeMessagesConfig(
merges usage from message_start and message_delta but ignores
message_stop. This method buffers message_delta and, when
message_stop arrives with cache usage, merges those fields into the
message_delta usage and also updates the input_tokens on
message_delta to include the full count (uncached + cache_creation +
cache_read).
message_delta usage. input_tokens is kept as the uncached-only
count; downstream calculate_usage adds cache tokens to
prompt_tokens.
"""
_CACHE_FIELDS = ("cache_creation_input_tokens", "cache_read_input_tokens")
pending_delta = None
@ -569,12 +569,7 @@ class AmazonAnthropicClaudeMessagesConfig(
raw_input = stop_usage.get("input_tokens")
if raw_input is not None:
uncached = raw_input if isinstance(raw_input, int) else 0
raw_cc = delta_usage.get("cache_creation_input_tokens", 0)
cache_creation = raw_cc if isinstance(raw_cc, int) else 0
raw_cr = delta_usage.get("cache_read_input_tokens", 0)
cache_read = raw_cr if isinstance(raw_cr, int) else 0
delta_usage["input_tokens"] = uncached + cache_creation + cache_read
delta_usage["input_tokens"] = raw_input if isinstance(raw_input, int) else 0
if delta_usage:
pending_delta["usage"] = delta_usage # type: ignore[arg-type]

View file

@ -1,5 +1,6 @@
import json
import ssl
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
from typing import (
TYPE_CHECKING,
Any,
@ -5027,6 +5028,16 @@ class BaseLLMHTTPHandler:
litellm_params={},
)
ws_url = http_url.replace("https://", "wss://").replace("http://", "ws://")
# OpenAI's WebSocket responses endpoint requires ?model= in the URL,
# matching the Realtime API convention (wss://.../v1/realtime?model=...).
# Use urllib.parse so existing query params (e.g. api-version) are preserved.
_parsed = urlparse(ws_url)
_qs = parse_qs(_parsed.query)
if "model" not in _qs:
_qs["model"] = [model]
ws_url = urlunparse(
_parsed._replace(query=urlencode({k: v[0] for k, v in _qs.items()}))
)
try:
ssl_context = get_shared_realtime_ssl_context()

View file

@ -5,6 +5,7 @@ Handles extraction of skill content (SKILL.md) from stored ZIP files
and injection into the system prompt for non-Anthropic models.
"""
import posixpath
import zipfile
from io import BytesIO
from typing import Any, Dict, List, Optional
@ -103,8 +104,18 @@ class SkillPromptInjectionHandler:
else:
clean_path = name
if clean_path:
files[clean_path] = zf.read(name)
if not clean_path:
continue
# Ensure the path stays within the intended directory
normalized = posixpath.normpath(clean_path)
if normalized.startswith("..") or posixpath.isabs(normalized):
verbose_logger.warning(
f"SkillPromptInjectionHandler: Skipping entry with invalid path in skill {skill.skill_id}: {name}"
)
continue
files[normalized] = zf.read(name)
except Exception as e:
verbose_logger.warning(
f"SkillPromptInjectionHandler: Error extracting files from skill {skill.skill_id}: {e}"

View file

@ -94,9 +94,15 @@ class SkillsSandboxExecutor:
# Create a temp directory to stage files
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_abs = os.path.abspath(tmpdir)
for path, content in skill_files.items():
# Create the file in temp directory
local_path = os.path.join(tmpdir, path)
local_path = os.path.abspath(os.path.join(tmpdir, path))
if not local_path.startswith(tmpdir_abs + os.sep):
verbose_logger.warning(
f"SkillsSandboxExecutor: Skipping file with invalid path: {path}"
)
continue
os.makedirs(os.path.dirname(local_path), exist_ok=True)
with open(local_path, "wb") as f:
f.write(content)

View file

@ -494,10 +494,12 @@ class LiteLLMRoutes(enum.Enum):
"/v2/key/info",
"/model_group/info",
"/health",
"/health/services",
"/key/list",
"/user/filter/ui",
"/models",
"/v1/models",
"/sso/get/ui_settings",
]
# NOTE: ROUTES ONLY FOR MASTER KEY - only the Master Key should be able to Reset Spend
@ -566,6 +568,8 @@ class LiteLLMRoutes(enum.Enum):
"/spend/tags",
"/spend/calculate",
"/spend/logs",
"/spend/logs/ui",
"/spend/logs/session/ui",
"/cost/estimate",
]
@ -581,6 +585,7 @@ class LiteLLMRoutes(enum.Enum):
"/global/spend/report",
"/global/spend/provider",
"/global/spend/tags",
"/global/spend/all_tag_names",
]
public_routes = set(
@ -602,6 +607,9 @@ class LiteLLMRoutes(enum.Enum):
]
)
# Retained for backwards compatibility with JWT auth configs that reference
# "ui_routes" in admin_allowed_routes. Not used by the proxy's own route
# authorization — UI tokens now go through the same RBAC path as API tokens.
ui_routes = [
"/sso",
"/sso/get/ui_settings",
@ -627,19 +635,16 @@ class LiteLLMRoutes(enum.Enum):
internal_user_routes = (
[
"/global/spend/tags",
"/global/spend/keys",
"/global/spend/models",
"/global/spend/provider",
"/global/spend/end_users",
"/global/activity",
"/global/activity/model",
"/global/activity/cache_hits",
"/v1/models/{model_id}",
"/models/{model_id}",
"/guardrails/list",
"/v2/guardrails/list",
]
+ spend_tracking_routes
+ global_spend_tracking_routes
+ key_management_routes
)
@ -694,6 +699,9 @@ class LiteLLMRoutes(enum.Enum):
"/tag/list",
"/audit",
"/audit/{id}",
"/global/activity",
"/global/activity/model",
"/global/activity/cache_hits",
] + info_routes
# All routes accesible by an Org Admin

View file

@ -28,6 +28,7 @@ from litellm.types.agents import (
MakeAgentsPublicRequest,
PatchAgentRequest,
)
from litellm.litellm_core_utils.litellm_logging import _get_masked_values
from litellm.types.llms.custom_http import httpxSpecialProvider
from litellm.types.proxy.management_endpoints.common_daily_activity import (
SpendAnalyticsPaginatedResponse,
@ -36,6 +37,28 @@ from litellm.types.proxy.management_endpoints.common_daily_activity import (
router = APIRouter()
def _redact_sensitive_agent_fields(
agents: List[AgentResponse],
) -> List[AgentResponse]:
"""
Return copies of the given agents with sensitive configuration fields
redacted. The original objects are not modified.
"""
redacted: List[AgentResponse] = []
for agent in agents:
copy = agent.model_copy(deep=True)
copy.static_headers = None
copy.extra_headers = None
if copy.litellm_params:
copy.litellm_params = _get_masked_values(
copy.litellm_params,
unmasked_length=4,
number_of_asterisks=4,
)
redacted.append(copy)
return redacted
def _check_agent_management_permission(user_api_key_dict: UserAPIKeyAuth) -> None:
"""
Raises HTTP 403 if the caller does not have permission to create, update,
@ -183,6 +206,14 @@ async def get_agents(
agent.agent_id in litellm.public_agent_groups
)
# Redact sensitive fields for non-admin users
is_admin = (
user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN
or user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value
)
if not is_admin:
returned_agents = _redact_sensitive_agent_fields(returned_agents)
if health_check:
agents_with_url = [
agent
@ -399,6 +430,14 @@ async def get_agent_by_id(
status_code=404, detail=f"Agent with ID {agent_id} not found"
)
# Redact sensitive fields for non-admin users
is_admin = (
user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN
or user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value
)
if not is_admin:
agent = _redact_sensitive_agent_fields([agent])[0]
return agent
except HTTPException:
raise

View file

@ -1,5 +1,5 @@
#### Analytics Endpoints #####
from datetime import datetime
from datetime import datetime, timezone
from typing import List, Optional
import fastapi
@ -58,8 +58,10 @@ async def get_global_activity(
detail={"error": "Please provide start_date and end_date"},
)
start_date_obj = datetime.strptime(start_date, "%Y-%m-%d")
end_date_obj = datetime.strptime(end_date, "%Y-%m-%d")
start_date_obj = datetime.strptime(start_date, "%Y-%m-%d").replace(
tzinfo=timezone.utc
)
end_date_obj = datetime.strptime(end_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
from litellm.proxy.proxy_server import prisma_client
@ -83,8 +85,9 @@ async def get_global_activity(
SUM(CASE WHEN sl."cache_hit" != 'True' THEN sl."completion_tokens" ELSE 0 END) AS generated_completion_tokens
FROM "LiteLLM_SpendLogs" sl
LEFT JOIN "LiteLLM_VerificationToken" vt ON sl."api_key" = vt."token"
WHERE
sl."startTime" >= $1::timestamptz AND "startTime" < ($2::timestamptz + INTERVAL \'1 day\')
WHERE
sl."startTime" >= ($1::timestamptz AT TIME ZONE 'UTC')
AND sl."startTime" < (($2::timestamptz + INTERVAL '1 day') AT TIME ZONE 'UTC')
GROUP BY
vt."key_alias",
sl."call_type",

View file

@ -196,9 +196,7 @@ def _is_model_cost_zero(
return True
def _is_cost_explicitly_configured(
model: str, llm_router: "Router"
) -> bool:
def _is_cost_explicitly_configured(model: str, llm_router: "Router") -> bool:
"""
Check if any deployment in the model group has cost fields explicitly
set in its litellm.model_cost entry.
@ -215,10 +213,7 @@ def _is_cost_explicitly_configured(
if model_id is None:
continue
raw_entry = litellm.model_cost.get(model_id, {})
if (
"input_cost_per_token" in raw_entry
or "output_cost_per_token" in raw_entry
):
if "input_cost_per_token" in raw_entry or "output_cost_per_token" in raw_entry:
return True
return False
@ -596,17 +591,12 @@ async def common_checks( # noqa: PLR0915
user_object=user_object, route=route, request_body=request_body
)
token_team = getattr(valid_token, "team_id", None)
token_type: Literal["ui", "api"] = (
"ui" if token_team is not None and token_team == "litellm-dashboard" else "api"
)
_is_route_allowed = _is_allowed_route(
_is_route_allowed = _is_api_route_allowed(
route=route,
token_type=token_type,
user_obj=user_object,
request=request,
request_data=request_body,
valid_token=valid_token,
user_obj=user_object,
)
# 11. [OPTIONAL] Vector store checks - is the object allowed to access the vector store
@ -629,31 +619,6 @@ async def common_checks( # noqa: PLR0915
return True
def _is_ui_route(
route: str,
user_obj: Optional[LiteLLM_UserTable] = None,
) -> bool:
"""
- Check if the route is a UI used route
"""
# this token is only used for managing the ui
allowed_routes = LiteLLMRoutes.ui_routes.value
# check if the current route startswith any of the allowed routes
if (
route is not None
and isinstance(route, str)
and any(route.startswith(allowed_route) for allowed_route in allowed_routes)
):
# Do something if the current route starts with any of the allowed routes
return True
elif any(
RouteChecks._route_matches_pattern(route=route, pattern=allowed_route)
for allowed_route in allowed_routes
):
return True
return False
def _get_user_role(
user_obj: Optional[LiteLLM_UserTable],
) -> Optional[LitellmUserRoles]:
@ -717,30 +682,6 @@ def _is_user_proxy_admin(user_obj: Optional[LiteLLM_UserTable]):
return False
def _is_allowed_route(
route: str,
token_type: Literal["ui", "api"],
request: Request,
request_data: dict,
valid_token: Optional[UserAPIKeyAuth],
user_obj: Optional[LiteLLM_UserTable] = None,
) -> bool:
"""
- Route b/w ui token check and normal token check
"""
if token_type == "ui" and _is_ui_route(route=route, user_obj=user_obj):
return True
else:
return _is_api_route_allowed(
route=route,
request=request,
request_data=request_data,
valid_token=valid_token,
user_obj=user_obj,
)
def _allowed_routes_check(user_route: str, allowed_routes: list) -> bool:
"""
Return if a user is allowed to access route. Helper function for `allowed_routes_check`.

View file

@ -60,12 +60,20 @@ def _get_guardrails_list_response(
"""
Helper function to get the guardrails list response
"""
from litellm.litellm_core_utils.litellm_logging import _get_masked_values
guardrail_configs: List[GuardrailInfoResponse] = []
for guardrail in guardrails_config:
litellm_params = guardrail.get("litellm_params") or {}
masked_params = _get_masked_values(
litellm_params,
unmasked_length=4,
number_of_asterisks=4,
)
guardrail_configs.append(
GuardrailInfoResponse(
guardrail_name=guardrail.get("guardrail_name"),
litellm_params=guardrail.get("litellm_params"),
litellm_params=masked_params,
guardrail_info=guardrail.get("guardrail_info"),
)
)

View file

@ -456,6 +456,34 @@ def handle_key_type(data: GenerateKeyRequest, data_json: dict) -> dict:
return data_json
def _check_allowed_routes_caller_permission(
allowed_routes: Optional[list],
user_api_key_dict: UserAPIKeyAuth,
) -> None:
"""
Only proxy admins may set `allowed_routes` on a key.
`allowed_routes` bypasses the standard role-based route gate in
RouteChecks.non_proxy_admin_allowed_routes_check, so if a non-admin is
allowed to set it they can grant themselves access to any endpoint.
Non-admins should use `key_type` to pick a preset route bucket instead.
"""
# Empty list is the default on GenerateKeyRequest — treat as "not set".
if not allowed_routes:
return
if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value:
return
raise HTTPException(
status_code=403,
detail={
"error": (
"Only proxy admins can set `allowed_routes` on a key. "
"Use `key_type` to pick a preset route bucket instead."
)
},
)
async def validate_team_id_used_in_service_account_request(
team_id: Optional[str],
prisma_client: Optional[PrismaClient],
@ -740,9 +768,9 @@ async def _common_key_generation_helper( # noqa: PLR0915
request_type="key", **data_json, table_name="key"
)
response[
"soft_budget"
] = data.soft_budget # include the user-input soft budget in the response
response["soft_budget"] = (
data.soft_budget
) # include the user-input soft budget in the response
response = GenerateKeyResponse(**response)
@ -1254,6 +1282,12 @@ async def generate_key_fn(
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=message
)
_check_allowed_routes_caller_permission(
allowed_routes=data.allowed_routes,
user_api_key_dict=user_api_key_dict,
)
# For non-admin internal users: auto-assign caller's user_id if not provided
# This prevents creating unbound keys with no user association (LIT-1884)
_is_proxy_admin = (
@ -1888,6 +1922,11 @@ async def _validate_update_key_data(
"""Validate permissions and constraints for key update."""
_is_proxy_admin = user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value
_check_allowed_routes_caller_permission(
allowed_routes=data.allowed_routes,
user_api_key_dict=user_api_key_dict,
)
# Prevent non-admin from removing user_id (setting to empty string) (LIT-1884)
if data.user_id is not None and data.user_id == "" and not _is_proxy_admin:
raise HTTPException(
@ -3233,10 +3272,10 @@ async def delete_verification_tokens(
try:
if prisma_client:
tokens = [_hash_token_if_needed(token=key) for key in tokens]
_keys_being_deleted: List[
LiteLLM_VerificationToken
] = await prisma_client.db.litellm_verificationtoken.find_many(
where={"token": {"in": tokens}}
_keys_being_deleted: List[LiteLLM_VerificationToken] = (
await prisma_client.db.litellm_verificationtoken.find_many(
where={"token": {"in": tokens}}
)
)
if len(_keys_being_deleted) == 0:
@ -3436,9 +3475,9 @@ async def _rotate_master_key( # noqa: PLR0915
from litellm.proxy.proxy_server import proxy_config
try:
models: Optional[
List
] = await prisma_client.db.litellm_proxymodeltable.find_many()
models: Optional[List] = (
await prisma_client.db.litellm_proxymodeltable.find_many()
)
except Exception:
models = None
# 2. process model table
@ -4078,11 +4117,11 @@ async def validate_key_list_check(
param="user_id",
code=status.HTTP_403_FORBIDDEN,
)
complete_user_info_db_obj: Optional[
BaseModel
] = await prisma_client.db.litellm_usertable.find_unique(
where={"user_id": user_api_key_dict.user_id},
include={"organization_memberships": True},
complete_user_info_db_obj: Optional[BaseModel] = (
await prisma_client.db.litellm_usertable.find_unique(
where={"user_id": user_api_key_dict.user_id},
include={"organization_memberships": True},
)
)
if complete_user_info_db_obj is None:
@ -4165,10 +4204,10 @@ async def _fetch_user_team_objects(
if complete_user_info is None or not complete_user_info.teams:
return []
teams: Optional[
List[BaseModel]
] = await prisma_client.db.litellm_teamtable.find_many(
where={"team_id": {"in": complete_user_info.teams}}
teams: Optional[List[BaseModel]] = (
await prisma_client.db.litellm_teamtable.find_many(
where={"team_id": {"in": complete_user_info.teams}}
)
)
if teams is None:
return []

View file

@ -223,7 +223,8 @@ async def get_global_activity_internal_user(
COUNT(*) AS api_requests,
SUM(total_tokens) AS total_tokens
FROM "LiteLLM_SpendLogs"
WHERE "startTime" >= $1::timestamptz AND "startTime" < ($2::timestamptz + INTERVAL \'1 day\')
WHERE "startTime" >= ($1::timestamptz AT TIME ZONE 'UTC')
AND "startTime" < (($2::timestamptz + INTERVAL '1 day') AT TIME ZONE 'UTC')
AND "user" = $3
GROUP BY date_trunc('day', "startTime")
"""
@ -282,8 +283,10 @@ async def get_global_activity(
detail={"error": "Please provide start_date and end_date"},
)
start_date_obj = datetime.strptime(start_date, "%Y-%m-%d")
end_date_obj = datetime.strptime(end_date, "%Y-%m-%d")
start_date_obj = datetime.strptime(start_date, "%Y-%m-%d").replace(
tzinfo=timezone.utc
)
end_date_obj = datetime.strptime(end_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
from litellm.proxy.proxy_server import prisma_client
@ -307,7 +310,8 @@ async def get_global_activity(
COUNT(*) AS api_requests,
SUM(total_tokens) AS total_tokens
FROM "LiteLLM_SpendLogs"
WHERE "startTime" >= $1::timestamptz AND "startTime" < ($2::timestamptz + INTERVAL \'1 day\')
WHERE "startTime" >= ($1::timestamptz AT TIME ZONE 'UTC')
AND "startTime" < (($2::timestamptz + INTERVAL '1 day') AT TIME ZONE 'UTC')
GROUP BY date_trunc('day', "startTime")
"""
db_response = await prisma_client.db.query_raw(
@ -366,7 +370,8 @@ async def get_global_activity_model_internal_user(
COUNT(*) AS api_requests,
SUM(total_tokens) AS total_tokens
FROM "LiteLLM_SpendLogs"
WHERE "startTime" >= $1::timestamptz AND "startTime" < ($2::timestamptz + INTERVAL \'1 day\')
WHERE "startTime" >= ($1::timestamptz AT TIME ZONE 'UTC')
AND "startTime" < (($2::timestamptz + INTERVAL '1 day') AT TIME ZONE 'UTC')
AND "user" = $3
GROUP BY model_group, date_trunc('day', "startTime")
"""
@ -448,8 +453,10 @@ async def get_global_activity_model(
detail={"error": "Please provide start_date and end_date"},
)
start_date_obj = datetime.strptime(start_date, "%Y-%m-%d")
end_date_obj = datetime.strptime(end_date, "%Y-%m-%d")
start_date_obj = datetime.strptime(start_date, "%Y-%m-%d").replace(
tzinfo=timezone.utc
)
end_date_obj = datetime.strptime(end_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
from litellm.proxy.proxy_server import prisma_client
@ -474,7 +481,8 @@ async def get_global_activity_model(
COUNT(*) AS api_requests,
SUM(total_tokens) AS total_tokens
FROM "LiteLLM_SpendLogs"
WHERE "startTime" >= $1::timestamptz AND "startTime" < ($2::timestamptz + INTERVAL \'1 day\')
WHERE "startTime" >= ($1::timestamptz AT TIME ZONE 'UTC')
AND "startTime" < (($2::timestamptz + INTERVAL '1 day') AT TIME ZONE 'UTC')
GROUP BY model_group, date_trunc('day', "startTime")
"""
db_response = await prisma_client.db.query_raw(
@ -600,8 +608,10 @@ async def get_global_activity_exceptions_per_deployment(
detail={"error": "Please provide start_date and end_date"},
)
start_date_obj = datetime.strptime(start_date, "%Y-%m-%d")
end_date_obj = datetime.strptime(end_date, "%Y-%m-%d")
start_date_obj = datetime.strptime(start_date, "%Y-%m-%d").replace(
tzinfo=timezone.utc
)
end_date_obj = datetime.strptime(end_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
from litellm.proxy.proxy_server import prisma_client
@ -619,7 +629,8 @@ async def get_global_activity_exceptions_per_deployment(
FROM
"LiteLLM_ErrorLogs"
WHERE
"startTime" >= $1::timestamptz AND "startTime" < ($2::timestamptz + INTERVAL \'1 day\')
"startTime" >= ($1::timestamptz AT TIME ZONE 'UTC')
AND "startTime" < (($2::timestamptz + INTERVAL '1 day') AT TIME ZONE 'UTC')
AND model_group = $3
AND status_code = '429'
GROUP BY
@ -732,8 +743,10 @@ async def get_global_activity_exceptions(
detail={"error": "Please provide start_date and end_date"},
)
start_date_obj = datetime.strptime(start_date, "%Y-%m-%d")
end_date_obj = datetime.strptime(end_date, "%Y-%m-%d")
start_date_obj = datetime.strptime(start_date, "%Y-%m-%d").replace(
tzinfo=timezone.utc
)
end_date_obj = datetime.strptime(end_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
from litellm.proxy.proxy_server import prisma_client
@ -750,7 +763,8 @@ async def get_global_activity_exceptions(
FROM
"LiteLLM_ErrorLogs"
WHERE
"startTime" >= $1::timestamptz AND "startTime" < ($2::timestamptz + INTERVAL \'1 day\')
"startTime" >= ($1::timestamptz AT TIME ZONE 'UTC')
AND "startTime" < (($2::timestamptz + INTERVAL '1 day') AT TIME ZONE 'UTC')
AND model_group = $3
AND status_code = '429'
GROUP BY
@ -837,8 +851,10 @@ async def get_global_spend_provider(
detail={"error": "Please provide start_date and end_date"},
)
start_date_obj = datetime.strptime(start_date, "%Y-%m-%d")
end_date_obj = datetime.strptime(end_date, "%Y-%m-%d")
start_date_obj = datetime.strptime(start_date, "%Y-%m-%d").replace(
tzinfo=timezone.utc
)
end_date_obj = datetime.strptime(end_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
from litellm.proxy.proxy_server import llm_router, prisma_client
@ -863,7 +879,8 @@ async def get_global_spend_provider(
model_id,
SUM(spend) AS spend
FROM "LiteLLM_SpendLogs"
WHERE "startTime" >= $1::timestamptz AND "startTime" < ($2::timestamptz + INTERVAL \'1 day\')
WHERE "startTime" >= ($1::timestamptz AT TIME ZONE 'UTC')
AND "startTime" < (($2::timestamptz + INTERVAL '1 day') AT TIME ZONE 'UTC')
AND length(model_id) > 0
AND "user" = $3
GROUP BY model_id
@ -877,7 +894,9 @@ async def get_global_spend_provider(
model_id,
SUM(spend) AS spend
FROM "LiteLLM_SpendLogs"
WHERE "startTime" >= $1::timestamptz AND "startTime" < ($2::timestamptz + INTERVAL \'1 day\') AND length(model_id) > 0
WHERE "startTime" >= ($1::timestamptz AT TIME ZONE 'UTC')
AND "startTime" < (($2::timestamptz + INTERVAL '1 day') AT TIME ZONE 'UTC')
AND length(model_id) > 0
GROUP BY model_id
"""
db_response = await prisma_client.db.query_raw(
@ -996,8 +1015,10 @@ async def get_global_spend_report(
detail={"error": "Please provide start_date and end_date"},
)
start_date_obj = datetime.strptime(start_date, "%Y-%m-%d")
end_date_obj = datetime.strptime(end_date, "%Y-%m-%d")
start_date_obj = datetime.strptime(start_date, "%Y-%m-%d").replace(
tzinfo=timezone.utc
)
end_date_obj = datetime.strptime(end_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
from litellm.proxy.proxy_server import premium_user, prisma_client
@ -1029,7 +1050,9 @@ async def get_global_spend_report(
FROM
"LiteLLM_SpendLogs" sl
WHERE
sl."startTime" >= $1::timestamptz AND "startTime" < ($2::timestamptz + INTERVAL \'1 day\') AND sl.api_key = $3
sl."startTime" >= ($1::timestamptz AT TIME ZONE 'UTC')
AND sl."startTime" < (($2::timestamptz + INTERVAL '1 day') AT TIME ZONE 'UTC')
AND sl.api_key = $3
GROUP BY
sl.api_key,
sl.model
@ -1074,7 +1097,9 @@ async def get_global_spend_report(
FROM
"LiteLLM_SpendLogs" sl
WHERE
sl."startTime" >= $1::timestamptz AND "startTime" < ($2::timestamptz + INTERVAL \'1 day\') AND sl.user = $3
sl."startTime" >= ($1::timestamptz AT TIME ZONE 'UTC')
AND sl."startTime" < (($2::timestamptz + INTERVAL '1 day') AT TIME ZONE 'UTC')
AND sl.user = $3
GROUP BY
sl.api_key,
sl.model
@ -1128,7 +1153,8 @@ async def get_global_spend_report(
ON
sl.team_id = tt.team_id
WHERE
sl."startTime" >= $1::timestamptz AND "startTime" < ($2::timestamptz + INTERVAL \'1 day\')
sl."startTime" >= ($1::timestamptz AT TIME ZONE 'UTC')
AND sl."startTime" < (($2::timestamptz + INTERVAL '1 day') AT TIME ZONE 'UTC')
GROUP BY
date_trunc('day', sl."startTime"),
tt.team_alias,
@ -1187,7 +1213,8 @@ async def get_global_spend_report(
FROM
"LiteLLM_SpendLogs" sl
WHERE
sl."startTime" >= $1::timestamptz AND "startTime" < ($2::timestamptz + INTERVAL \'1 day\')
sl."startTime" >= ($1::timestamptz AT TIME ZONE 'UTC')
AND sl."startTime" < (($2::timestamptz + INTERVAL '1 day') AT TIME ZONE 'UTC')
GROUP BY
date_trunc('day', sl."startTime"),
customer,
@ -1244,7 +1271,8 @@ async def get_global_spend_report(
FROM
"LiteLLM_SpendLogs" sl
WHERE
sl."startTime" >= $1::timestamptz AND "startTime" < ($2::timestamptz + INTERVAL \'1 day\')
sl."startTime" >= ($1::timestamptz AT TIME ZONE 'UTC')
AND sl."startTime" < (($2::timestamptz + INTERVAL '1 day') AT TIME ZONE 'UTC')
GROUP BY
sl.api_key,
sl.model
@ -1428,6 +1456,16 @@ async def _get_spend_report_for_time_range(
)
return None
# Normalize string inputs to tz-aware UTC datetimes so Prisma serializes
# them with an explicit +00:00 suffix. Raw strings get bound as untyped
# text, which forces Postgres to parse `::timestamptz` using the DB
# session timezone and drifts the window by the offset even with the
# AT TIME ZONE 'UTC' wrap below.
start_date_obj = datetime.strptime(start_date, "%Y-%m-%d").replace(
tzinfo=timezone.utc
)
end_date_obj = datetime.strptime(end_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
try:
sql_query = """
SELECT
@ -1438,27 +1476,31 @@ async def _get_spend_report_for_time_range(
LEFT JOIN
"LiteLLM_TeamTable" t ON s.team_id = t.team_id
WHERE
s."startTime" >= $1::date AND s."startTime" < ($2::date + INTERVAL '1 day')
s."startTime" >= ($1::timestamptz AT TIME ZONE 'UTC')
AND s."startTime" < (($2::timestamptz + INTERVAL '1 day') AT TIME ZONE 'UTC')
GROUP BY
t.team_alias
ORDER BY
total_spend DESC;
"""
response = await prisma_client.db.query_raw(sql_query, start_date, end_date)
response = await prisma_client.db.query_raw(
sql_query, start_date_obj, end_date_obj
)
# get spend per tag for today
sql_query = """
SELECT
SELECT
jsonb_array_elements_text(request_tags) AS individual_request_tag,
SUM(spend) AS total_spend
FROM "LiteLLM_SpendLogs"
WHERE "startTime" >= $1::timestamptz AND "startTime" < ($2::timestamptz + INTERVAL \'1 day\')
WHERE "startTime" >= ($1::timestamptz AT TIME ZONE 'UTC')
AND "startTime" < (($2::timestamptz + INTERVAL '1 day') AT TIME ZONE 'UTC')
GROUP BY individual_request_tag
ORDER BY total_spend DESC;
"""
spend_per_tag = await prisma_client.db.query_raw(
sql_query, start_date, end_date
sql_query, start_date_obj, end_date_obj
)
return response, spend_per_tag
@ -1910,11 +1952,17 @@ async def ui_view_spend_logs( # noqa: PLR0915
sql_params: List[Any] = []
p = 1 # parameter index counter
# Date range (always present)
sql_conditions.append(f'"startTime" >= ${p}::timestamptz')
# Date range (always present). Wrap the param side with
# `AT TIME ZONE 'UTC'` so comparison against the plain `timestamp`
# column does not depend on the DB session timezone (see #22529).
sql_conditions.append(
f"\"startTime\" >= (${p}::timestamptz AT TIME ZONE 'UTC')"
)
sql_params.append(start_date_obj)
p += 1
sql_conditions.append(f'"startTime" <= ${p}::timestamptz')
sql_conditions.append(
f"\"startTime\" <= (${p}::timestamptz AT TIME ZONE 'UTC')"
)
sql_params.append(end_date_obj)
p += 1
@ -2897,8 +2945,8 @@ async def global_spend_end_users(data: Optional[GlobalEndUsersSpend] = None):
sql_query = """
SELECT end_user, COUNT(*) AS total_count, SUM(spend) AS total_spend
FROM "LiteLLM_SpendLogs"
WHERE "startTime" >= $1::timestamptz
AND "startTime" < $2::timestamptz
WHERE "startTime" >= ($1::timestamptz AT TIME ZONE 'UTC')
AND "startTime" < ($2::timestamptz AT TIME ZONE 'UTC')
AND (
CASE
WHEN $3::TEXT IS NULL THEN TRUE

View file

@ -559,7 +559,8 @@ async def get_spend_by_team_and_customer(
ON
sl.team_id = tt.team_id
WHERE
sl."startTime" >= $1::timestamptz AND sl."startTime" < ($2::timestamptz + INTERVAL '1 day')
sl."startTime" >= ($1::timestamptz AT TIME ZONE 'UTC')
AND sl."startTime" < (($2::timestamptz + INTERVAL '1 day') AT TIME ZONE 'UTC')
AND sl.team_id = $3
AND sl.end_user = $4
GROUP BY

View file

@ -1,6 +1,7 @@
#### CRUD ENDPOINTS for UI Settings #####
import json
from typing import Any, Dict, List, Optional, Union
from urllib.parse import urlparse
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
@ -817,6 +818,29 @@ async def get_ui_theme_settings():
)
def _validate_public_image_url(value: Optional[str], field_name: str) -> None:
"""
Reject anything that isn't a plain http(s) URL with a host. This value is
later served via the unauthenticated /get_image endpoint, so local paths
like "/etc/passwd" or "file://..." must not be accepted.
"""
if value is None:
return
if not isinstance(value, str) or not value.strip():
return
parsed = urlparse(value.strip())
if parsed.scheme not in ("http", "https") or not parsed.netloc:
raise HTTPException(
status_code=400,
detail={
"error": (
f"Invalid {field_name}: must be an http(s) URL with a host. "
"Local filesystem paths and non-http schemes are not allowed."
)
},
)
@router.patch(
"/update/ui_theme_settings",
tags=["UI Theme Settings"],
@ -831,6 +855,9 @@ async def update_ui_theme_settings(theme_config: UIThemeConfig):
from litellm.proxy.proxy_server import proxy_config, store_model_in_db
_validate_public_image_url(theme_config.logo_url, "logo_url")
_validate_public_image_url(theme_config.favicon_url, "favicon_url")
if store_model_in_db is not True:
raise HTTPException(
status_code=500,

View file

@ -2645,7 +2645,7 @@ class PrismaClient:
raise e
async def _query_first_with_cached_plan_fallback(
self, sql_query: str
self, sql_query: str, *args
) -> Optional[dict]:
"""
Execute a query with automatic fallback for PostgreSQL cached plan errors.
@ -2664,7 +2664,7 @@ class PrismaClient:
Original exception if not a cached plan error
"""
try:
return await self.db.query_first(query=sql_query)
return await self.db.query_first(sql_query, *args)
except Exception as e:
error_str = str(e)
if "cached plan must not change result type" in error_str:
@ -2679,7 +2679,7 @@ class PrismaClient:
"retrying with fresh plan. This may occur during rolling deployments "
"when schema changes are applied."
)
return await self.db.query_first(query=sql_query_retry)
return await self.db.query_first(sql_query_retry, *args)
else:
raise
@ -2978,7 +2978,7 @@ class PrismaClient:
detail={"error": f"No token passed in. Token={token}"},
)
sql_query = f"""
sql_query = """
SELECT
v.*,
t.spend AS team_spend,
@ -3016,11 +3016,11 @@ class PrismaClient:
LEFT JOIN "LiteLLM_ProjectTable" AS p ON v.project_id = p.project_id
LEFT JOIN "LiteLLM_OrganizationTable" AS o ON v.organization_id = o.organization_id
LEFT JOIN "LiteLLM_BudgetTable" AS b2 ON o.budget_id = b2.budget_id
WHERE v.token = '{token}'
WHERE v.token = $1
"""
response = await self._query_first_with_cached_plan_fallback(
sql_query
sql_query, hashed_token
)
# If not found in main table, check deprecated keys (grace period)

View file

@ -1967,11 +1967,18 @@ async def _aresponses_websocket(
)
# Extract params that we're passing explicitly to avoid duplicates in **kwargs
remaining_kwargs = {
k: v
for k, v in kwargs.items()
if k not in {"user_api_key_dict", "litellm_metadata"}
_explicit_keys = {
"user_api_key_dict",
"litellm_metadata",
"custom_llm_provider",
"model",
"websocket",
"litellm_logging_obj",
"api_base",
"api_key",
"timeout",
}
remaining_kwargs = {k: v for k, v in kwargs.items() if k not in _explicit_keys}
await base_llm_http_handler.async_responses_websocket(
model=model,

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "litellm"
version = "1.83.5"
version = "1.83.6"
description = "Library to easily interface with LLM API providers"
authors = ["BerriAI"]
license = "MIT"
@ -181,7 +181,7 @@ requires = ["poetry-core", "wheel"]
build-backend = "poetry.core.masonry.api"
[tool.commitizen]
version = "1.83.5"
version = "1.83.6"
version_files = [
"pyproject.toml:^version"
]

View file

@ -934,10 +934,7 @@ async def mock_user_object(*args, **kwargs):
user_id = kwargs.get("user_id")
user_email = kwargs.get("user_email")
return LiteLLM_UserTable(
spend=0,
user_id=user_id,
max_budget=None,
user_email=user_email
spend=0, user_id=user_id, max_budget=None, user_email=user_email
)
@ -1170,15 +1167,13 @@ async def test_end_user_jwt_auth(monkeypatch):
# use generated key to auth in
from litellm import Router
from litellm.types.router import RouterGeneralSettings
# Create a router with pass_through_all_models enabled
router = Router(
model_list=[],
router_general_settings=RouterGeneralSettings(
pass_through_all_models=True
),
router_general_settings=RouterGeneralSettings(pass_through_all_models=True),
)
setattr(litellm.proxy.proxy_server, "premium_user", True)
setattr(
litellm.proxy.proxy_server,
@ -1196,7 +1191,7 @@ async def test_end_user_jwt_auth(monkeypatch):
cost_tracking()
result = await user_api_key_auth(request=request, api_key=bearer_token)
# Assert that end_user_id is correctly extracted from JWT token's 'sub' field
assert result.end_user_id == "81b3e52a-67a6-4efb-9645-70527e101479"
@ -1228,7 +1223,9 @@ async def test_end_user_jwt_auth(monkeypatch):
),
)
with patch("litellm.acompletion", new=AsyncMock(return_value=mock_response)) as mock_completion:
with patch(
"litellm.acompletion", new=AsyncMock(return_value=mock_response)
) as mock_completion:
resp = await chat_completion(
request=request,
fastapi_response=temp_response,
@ -1243,10 +1240,13 @@ async def test_end_user_jwt_auth(monkeypatch):
# Verify the completion was called with correct end_user_id
mock_completion.assert_called_once()
call_kwargs = mock_completion.call_args.kwargs
# end_user_id is passed in metadata as 'user_api_key_end_user_id'
metadata = call_kwargs.get("metadata", {})
assert metadata.get("user_api_key_end_user_id") == "81b3e52a-67a6-4efb-9645-70527e101479"
assert (
metadata.get("user_api_key_end_user_id")
== "81b3e52a-67a6-4efb-9645-70527e101479"
)
def test_can_rbac_role_call_route():
@ -1278,13 +1278,13 @@ def test_user_api_key_auth_jwt_hashing():
"""
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.handle_jwt import JWTHandler
# Test with a JWT token (3 parts separated by dots)
jwt_token = "test-jwt-token-header.payload.signature"
# Create UserAPIKeyAuth instance with JWT
user_auth = UserAPIKeyAuth(api_key=jwt_token)
# Verify that the API key is hashed with "hashed-jwt-" prefix
# critical - the raw JWT token should not be in the api_key or token
assert user_auth.api_key.startswith("hashed-jwt-")
@ -1292,19 +1292,18 @@ def test_user_api_key_auth_jwt_hashing():
assert jwt_token not in user_auth.api_key
assert jwt_token not in user_auth.token
# Test with a regular API key (should not be hashed)
regular_api_key = "sk-1234567890abcdef"
user_auth_regular = UserAPIKeyAuth(api_key=regular_api_key)
# Verify that regular API key is hashed normally (without "hashed-jwt-" prefix)
assert not user_auth_regular.api_key.startswith("hashed-jwt-")
assert not user_auth_regular.token.startswith("hashed-jwt-")
# Test with a non-JWT, non-sk string (should not be hashed)
non_jwt_key = "some-random-key"
user_auth_non_jwt = UserAPIKeyAuth(api_key=non_jwt_key)
# Verify that non-JWT key is not hashed
assert user_auth_non_jwt.api_key == non_jwt_key
assert user_auth_non_jwt.token == non_jwt_key
@ -1315,19 +1314,19 @@ def test_jwt_handler_is_jwt_static_method():
Test that JWTHandler.is_jwt is a static method and works correctly
"""
from litellm.proxy.auth.handle_jwt import JWTHandler
# Test with valid JWT format
valid_jwt = "test-jwt-token-header.payload.signature"
assert JWTHandler.is_jwt(valid_jwt) == True
# Test with invalid JWT format (only 2 parts)
invalid_jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ"
assert JWTHandler.is_jwt(invalid_jwt) == False
# Test with regular API key
regular_key = "sk-1234567890abcdef"
assert JWTHandler.is_jwt(regular_key) == False
# Test with empty string
assert JWTHandler.is_jwt("") == False
@ -1461,7 +1460,13 @@ async def test_auth_jwt_es256_jwk_path(monkeypatch):
now = int(time.time())
token = jwt.encode(
{"sub": "alice", "aud": "litellm-proxy", "iss": "http://example", "iat": now, "exp": now + 300},
{
"sub": "alice",
"aud": "litellm-proxy",
"iss": "http://example",
"iat": now,
"exp": now + 300,
},
ec_priv_pem,
algorithm="ES256",
headers={"kid": "ec1"},
@ -1508,7 +1513,13 @@ async def test_auth_jwt_rs256_regression(monkeypatch):
now = int(time.time())
token = jwt.encode(
{"sub": "bob", "aud": "litellm-proxy", "iss": "http://example", "iat": now, "exp": now + 300},
{
"sub": "bob",
"aud": "litellm-proxy",
"iss": "http://example",
"iat": now,
"exp": now + 300,
},
rsa_priv_pem,
algorithm="RS256",
headers={"kid": "rsa1"},
@ -1540,7 +1551,13 @@ async def test_auth_jwt_mismatched_key_fails(monkeypatch):
)
now = int(time.time())
token = jwt.encode(
{"sub": "mallory", "aud": "litellm-proxy", "iss": "http://example", "iat": now, "exp": now + 300},
{
"sub": "mallory",
"aud": "litellm-proxy",
"iss": "http://example",
"iat": now,
"exp": now + 300,
},
ec_priv_pem,
algorithm="ES256",
headers={"kid": "ec1"},
@ -1566,4 +1583,4 @@ async def test_auth_jwt_mismatched_key_fails(monkeypatch):
with patch.object(h, "get_public_key", new=AsyncMock(return_value=rsa_jwk)):
with pytest.raises(Exception) as exc:
await h.auth_jwt(token)
assert "Validation fails" in str(exc.value)
assert "Validation fails" in str(exc.value)

View file

@ -359,27 +359,38 @@ async def test_auth_with_allowed_routes(route, should_raise_error):
@pytest.mark.parametrize(
"route, user_role, expected_result",
"route, user_role, should_be_allowed",
[
# Proxy Admin checks
# Admin can access everything
("/config/update", "proxy_admin", True),
("/global/spend/logs", "proxy_admin", True),
("/key/delete", "proxy_admin", False),
("/key/generate", "proxy_admin", False),
("/key/regenerate", "proxy_admin", False),
# Internal User checks - allowed routes
("/global/activity/cache_hits", "proxy_admin", True),
# Internal User - allowed read-only routes
("/global/spend/logs", "internal_user", True),
("/key/delete", "internal_user", False),
("/key/generate", "internal_user", False),
("/key/82akk800000000jjsk/regenerate", "internal_user", False),
# Internal User Viewer
("/key/generate", "internal_user_viewer", False),
# Internal User checks - disallowed routes
("/spend/logs/ui", "internal_user", True),
("/global/activity/cache_hits", "internal_user", True),
("/health/services", "internal_user", True),
# Internal User - BLOCKED from admin routes (security fix)
("/config/update", "internal_user", False),
("/config/pass_through_endpoint", "internal_user", False),
("/config/field/update", "internal_user", False),
("/organization/member_add", "internal_user", False),
# Internal User Viewer - allowed spend routes only
("/spend/logs/ui", "internal_user_viewer", True),
("/global/spend/all_tag_names", "internal_user_viewer", True),
# Internal User Viewer - blocked from admin routes
("/config/update", "internal_user_viewer", False),
("/key/generate", "internal_user_viewer", False),
],
)
def test_is_ui_route_allowed(route, user_role, expected_result):
from litellm.proxy.auth.auth_checks import _is_ui_route
from litellm.proxy._types import LiteLLM_UserTable
def test_ui_token_route_access(route, user_role, should_be_allowed):
"""
Verify that UI tokens (team_id=litellm-dashboard) go through the same
RBAC checks as API tokens. Non-admin dashboard users must not be able
to access admin-only routes like /config/update.
"""
from litellm.proxy.auth.auth_checks import _is_api_route_allowed
from litellm.proxy._types import LiteLLM_UserTable, UserAPIKeyAuth
user_obj = LiteLLM_UserTable(
user_id="3b803c0e-666e-4e99-bd5c-6e534c07e297",
@ -395,18 +406,36 @@ def test_is_ui_route_allowed(route, user_role, expected_result):
organization_memberships=[],
)
received_args: dict = {
"route": route,
"user_obj": user_obj,
}
try:
assert _is_ui_route(**received_args) == expected_result
except Exception as e:
# If expected result is False, we expect an error
if expected_result is False:
pass
else:
raise e
valid_token = UserAPIKeyAuth(
user_id="3b803c0e-666e-4e99-bd5c-6e534c07e297",
team_id="litellm-dashboard",
user_role=user_role,
)
from starlette.datastructures import URL
from fastapi import Request
request = Request(scope={"type": "http"})
request._url = URL(url=route)
if should_be_allowed:
result = _is_api_route_allowed(
route=route,
request=request,
request_data={},
valid_token=valid_token,
user_obj=user_obj,
)
assert result is True
else:
with pytest.raises(Exception):
_is_api_route_allowed(
route=route,
request=request,
request_data={},
valid_token=valid_token,
user_obj=user_obj,
)
@pytest.mark.parametrize(
@ -684,7 +713,7 @@ async def test_soft_budget_alert():
def test_is_allowed_route():
from litellm.proxy.auth.auth_checks import _is_allowed_route
from litellm.proxy.auth.auth_checks import _is_api_route_allowed
from litellm.proxy._types import UserAPIKeyAuth
import datetime
@ -692,7 +721,6 @@ def test_is_allowed_route():
args = {
"route": "/embeddings",
"token_type": "api",
"request": request,
"request_data": {"input": ["hello world"], "model": "embedding-small"},
"valid_token": UserAPIKeyAuth(
@ -752,7 +780,7 @@ def test_is_allowed_route():
"user_obj": None,
}
assert _is_allowed_route(**args)
assert _is_api_route_allowed(**args)
@pytest.mark.parametrize(
@ -836,7 +864,6 @@ async def test_user_api_key_auth_websocket():
with patch(
"litellm.proxy.auth.user_api_key_auth.user_api_key_auth", autospec=True
) as mock_user_api_key_auth:
# Make the call to the WebSocket function
await user_api_key_auth_websocket(mock_websocket)
@ -845,10 +872,14 @@ async def test_user_api_key_auth_websocket():
# Get the request object that was passed to user_api_key_auth
request_arg = mock_user_api_key_auth.call_args.kwargs["request"]
# Verify that the request has headers set
assert hasattr(request_arg, "headers"), "Request object should have headers attribute"
assert "authorization" in request_arg.headers, "Request headers should contain authorization"
assert hasattr(
request_arg, "headers"
), "Request object should have headers attribute"
assert (
"authorization" in request_arg.headers
), "Request headers should contain authorization"
assert request_arg.headers["authorization"] == "Bearer some_api_key"
assert (
@ -1036,7 +1067,10 @@ async def test_jwt_non_admin_team_route_access(monkeypatch):
# Create request
request = Request(
scope={"type": "http", "headers": [(b"authorization", b"Bearer fake.jwt.token")]}
scope={
"type": "http",
"headers": [(b"authorization", b"Bearer fake.jwt.token")],
}
)
request._url = URL(url="/team/new")
@ -1101,14 +1135,14 @@ async def test_x_litellm_api_key():
ignored_key = "aj12445"
# Create request with headers as bytes
request = Request(
scope={
"type": "http"
}
)
request = Request(scope={"type": "http"})
request._url = URL(url="/chat/completions")
valid_token = await user_api_key_auth(request=request, api_key="Bearer " + ignored_key, custom_litellm_key_header=master_key)
valid_token = await user_api_key_auth(
request=request,
api_key="Bearer " + ignored_key,
custom_litellm_key_header=master_key,
)
assert valid_token.token == hash_token(master_key)
@ -1123,7 +1157,9 @@ async def test_user_api_key_from_query_param():
from litellm.proxy.proxy_server import hash_token, user_api_key_cache
user_key = "sk-query-1234"
user_api_key_cache.set_cache(key=hash_token(user_key), value=UserAPIKeyAuth(token=hash_token(user_key)))
user_api_key_cache.set_cache(
key=hash_token(user_key), value=UserAPIKeyAuth(token=hash_token(user_key))
)
setattr(litellm.proxy.proxy_server, "user_api_key_cache", user_api_key_cache)
setattr(litellm.proxy.proxy_server, "master_key", "sk-1234")
@ -1136,7 +1172,9 @@ async def test_user_api_key_from_query_param():
"query_string": f"alt=sse&key={user_key}".encode(),
}
)
request._url = URL(url=f"/v1beta/models/gemini:streamGenerateContent?alt=sse&key={user_key}")
request._url = URL(
url=f"/v1beta/models/gemini:streamGenerateContent?alt=sse&key={user_key}"
)
async def return_body():
return b"{}"
@ -1145,4 +1183,3 @@ async def test_user_api_key_from_query_param():
valid_token = await user_api_key_auth(request=request, api_key="")
assert valid_token.token == hash_token(user_key)

View file

@ -3,6 +3,7 @@ import json
import os
import sys
from datetime import datetime
from unittest.mock import Mock
import pytest
@ -125,7 +126,8 @@ async def test_bedrock_sse_wrapper_keeps_usage_in_message_start_and_message_delt
assert "usage" in delta_json
assert delta_json["usage"]["cache_creation_input_tokens"] == 1562
assert delta_json["usage"]["cache_read_input_tokens"] == 32392
assert delta_json["usage"]["input_tokens"] == 3 + 1562 + 32392
assert delta_json["usage"]["input_tokens"] == 3
assert delta_json["usage"]["output_tokens"] == 8
def test_chunk_parser_usage_transformation():
@ -402,3 +404,111 @@ def test_bedrock_messages_strips_output_config_with_output_format():
assert "output_config" not in result
assert "output_format" not in result
@pytest.mark.asyncio
async def test_promote_message_stop_usage_preserves_message_delta_output_tokens():
"""
Bedrock unified /messages streaming can send full usage on message_delta and a
conflicting smaller usage on message_stop (e.g. output_tokens 9 vs 12).
_promote_message_stop_usage must not replace message_delta output_tokens.
"""
cfg = AmazonAnthropicClaudeMessagesConfig()
async def _stream(): # type: ignore[return-type]
yield {
"type": "message_delta",
"delta": {"stop_reason": "end_turn", "stop_sequence": None},
"usage": {
"input_tokens": 3,
"cache_creation_input_tokens": 10553,
"cache_read_input_tokens": 25490,
"output_tokens": 12,
},
}
yield {
"type": "message_stop",
"usage": {"input_tokens": 3, "output_tokens": 9},
}
merged: list[dict] = []
async for chunk in cfg._promote_message_stop_usage(_stream()):
if isinstance(chunk, dict):
merged.append(chunk)
assert len(merged) >= 1
delta_out = merged[0]
assert delta_out["type"] == "message_delta"
assert delta_out["usage"]["output_tokens"] == 12
assert delta_out["usage"]["cache_creation_input_tokens"] == 10553
assert delta_out["usage"]["cache_read_input_tokens"] == 25490
assert delta_out["usage"]["input_tokens"] == 3
@pytest.mark.asyncio
async def test_unified_bedrock_messages_sse_usage_and_cost_claude_sonnet_46():
"""
End-to-end for Bedrock Invoke Anthropic Messages (unified) streaming path:
dict chunks -> _promote_message_stop_usage -> bedrock_sse_wrapper SSE bytes ->
same logging reconstruction as Anthropic /messages. Ensures token counts and
completion_cost match model_prices for us.anthropic.claude-sonnet-4-6.
"""
from litellm import completion_cost
from litellm.proxy.pass_through_endpoints.llm_provider_handlers.anthropic_passthrough_logging_handler import (
AnthropicPassthroughLoggingHandler,
)
cfg = AmazonAnthropicClaudeMessagesConfig()
async def _stream(): # type: ignore[return-type]
yield {
"type": "message_delta",
"delta": {"stop_reason": "end_turn", "stop_sequence": None},
"usage": {
"input_tokens": 3,
"cache_creation_input_tokens": 10553,
"cache_read_input_tokens": 25490,
"output_tokens": 12,
},
}
yield {
"type": "message_stop",
"usage": {"input_tokens": 3, "output_tokens": 9},
}
logging_obj = LiteLLMLoggingObj(
model="bedrock/us.anthropic.claude-sonnet-4-6",
messages=[{"role": "user", "content": "Hello"}],
stream=True,
call_type="chat",
start_time=datetime.now(),
litellm_call_id="test_unified_bedrock_messages_sse_cost",
function_id="test_unified_bedrock_messages_sse_cost",
)
collected: list[bytes] = []
async for sse in cfg.bedrock_sse_wrapper(
completion_stream=_stream(),
litellm_logging_obj=logging_obj,
request_body={"model": "us.anthropic.claude-sonnet-4-6"},
):
collected.append(sse)
built = AnthropicPassthroughLoggingHandler._build_complete_streaming_response(
all_chunks=collected,
model="us.anthropic.claude-sonnet-4-6",
litellm_logging_obj=Mock(),
)
assert built.usage is not None
assert built.usage.completion_tokens == 12
assert built.usage.prompt_tokens == 36046
assert built.usage.total_tokens == 36058
assert built.usage.cache_creation_input_tokens == 10553
assert built.usage.cache_read_input_tokens == 25490
cost = completion_cost(
completion_response=built,
model="bedrock/us.anthropic.claude-sonnet-4-6",
custom_llm_provider="bedrock",
)
assert cost == pytest.approx(0.052150725, rel=0, abs=1e-9)

View file

@ -8579,3 +8579,170 @@ def test_enforce_upperbound_no_config_is_noop():
assert data.tpm_limit == 999999
finally:
litellm.upperbound_key_generate_params = original
class TestAllowedRoutesCallerPermission:
"""
Non-admins must not be able to set `allowed_routes` on a key. The field
bypasses the role-based route gate in
RouteChecks.non_proxy_admin_allowed_routes_check, so allowing a non-admin
to populate it grants them arbitrary endpoint access.
"""
@pytest.mark.asyncio
async def test_non_admin_generate_key_with_allowed_routes_rejected(self):
data = GenerateKeyRequest(
key_alias="escalate",
allowed_routes=["/*"],
)
user_api_key_dict = UserAPIKeyAuth(
user_id="internal-user-123",
user_role=LitellmUserRoles.INTERNAL_USER,
)
mock_prisma_client = AsyncMock()
with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client), patch(
"litellm.proxy.proxy_server.user_api_key_cache", MagicMock()
), patch("litellm.proxy.proxy_server.user_custom_key_generate", None), patch(
"litellm.proxy.management_endpoints.key_management_endpoints._common_key_generation_helper",
new_callable=AsyncMock,
return_value=MagicMock(),
):
with pytest.raises(ProxyException) as exc_info:
await generate_key_fn(
data=data,
user_api_key_dict=user_api_key_dict,
litellm_changed_by=None,
)
assert str(exc_info.value.code) == "403"
assert "allowed_routes" in str(exc_info.value.message)
@pytest.mark.asyncio
async def test_admin_generate_key_with_allowed_routes_allowed(self):
data = GenerateKeyRequest(
key_alias="admin-key",
allowed_routes=["/chat/completions"],
user_id="admin-user",
)
user_api_key_dict = UserAPIKeyAuth(
user_id="admin-user",
user_role=LitellmUserRoles.PROXY_ADMIN,
)
mock_prisma_client = AsyncMock()
stub_response = MagicMock()
with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client), patch(
"litellm.proxy.proxy_server.user_api_key_cache", MagicMock()
), patch("litellm.proxy.proxy_server.user_custom_key_generate", None), patch(
"litellm.proxy.management_endpoints.key_management_endpoints._common_key_generation_helper",
new_callable=AsyncMock,
return_value=stub_response,
):
result = await generate_key_fn(
data=data,
user_api_key_dict=user_api_key_dict,
litellm_changed_by=None,
)
assert result is stub_response
@pytest.mark.asyncio
async def test_non_admin_generate_key_default_empty_allowed_routes_ok(self):
"""
Regression guard: GenerateKeyRequest.allowed_routes defaults to [], so
the helper must treat empty-list as "not set" or every non-admin key
creation breaks.
"""
data = GenerateKeyRequest(key_alias="plain-key")
user_api_key_dict = UserAPIKeyAuth(
user_id="internal-user-123",
user_role=LitellmUserRoles.INTERNAL_USER,
)
mock_prisma_client = AsyncMock()
stub_response = MagicMock()
with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client), patch(
"litellm.proxy.proxy_server.user_api_key_cache", MagicMock()
), patch("litellm.proxy.proxy_server.user_custom_key_generate", None), patch(
"litellm.proxy.management_endpoints.key_management_endpoints._common_key_generation_helper",
new_callable=AsyncMock,
return_value=stub_response,
):
result = await generate_key_fn(
data=data,
user_api_key_dict=user_api_key_dict,
litellm_changed_by=None,
)
assert result is stub_response
@pytest.mark.asyncio
async def test_non_admin_update_key_with_allowed_routes_rejected(self):
from litellm.proxy.management_endpoints.key_management_endpoints import (
update_key_fn,
)
data = UpdateKeyRequest(key="sk-test", allowed_routes=["/*"])
user_api_key_dict = UserAPIKeyAuth(
user_id="internal-user-123",
user_role=LitellmUserRoles.INTERNAL_USER,
)
mock_prisma_client = AsyncMock()
with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client), patch(
"litellm.proxy.proxy_server.user_api_key_cache", MagicMock()
), patch("litellm.proxy.proxy_server.user_custom_key_update", None), patch(
"litellm.proxy.proxy_server.llm_router", None
), patch("litellm.proxy.proxy_server.premium_user", True), patch(
"litellm.proxy.proxy_server.proxy_logging_obj", MagicMock()
), patch(
"litellm.proxy.management_endpoints.key_management_endpoints._get_and_validate_existing_key",
new_callable=AsyncMock,
return_value=MagicMock(),
):
with pytest.raises(ProxyException) as exc_info:
await update_key_fn(
request=MagicMock(),
data=data,
user_api_key_dict=user_api_key_dict,
litellm_changed_by=None,
)
assert str(exc_info.value.code) == "403"
assert "allowed_routes" in str(exc_info.value.message)
def test_jinja_prompt_manager_is_sandboxed():
"""
PromptManager renders user-supplied templates via /prompts/test, so its
jinja env must reject access to unsafe Python attributes like
``__class__`` and ``__mro__``.
"""
from jinja2.exceptions import SecurityError
from litellm.integrations.dotprompt.prompt_manager import PromptManager
pm = PromptManager()
template = pm.jinja_env.from_string("{{ ''.__class__.__mro__ }}")
with pytest.raises(SecurityError):
template.render()
def test_validate_public_image_url_rejects_local_paths():
from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import (
_validate_public_image_url,
)
for bad in ("/etc/passwd", "file:///etc/passwd", "../../etc/passwd"):
with pytest.raises(HTTPException) as exc_info:
_validate_public_image_url(bad, "logo_url")
assert exc_info.value.status_code == 400
def test_validate_public_image_url_accepts_http_and_noop_empty():
from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import (
_validate_public_image_url,
)
_validate_public_image_url("https://example.com/logo.png", "logo_url")
_validate_public_image_url("http://cdn.internal/logo.svg", "logo_url")
_validate_public_image_url(None, "logo_url")
_validate_public_image_url("", "logo_url")
_validate_public_image_url(" ", "logo_url")

View file

@ -60,25 +60,165 @@ async def test_spend_query_uses_timestamp_filtering():
params = call_args[1:]
# 1) SQL should NOT cast the startTime column to DATE (prevents index usage)
assert "::date" not in sql.lower(), \
"SQL should not use '::date' casting which prevents index usage"
assert "date(" not in sql.lower(), \
"SQL should not use DATE() function which prevents index usage"
assert (
"::date" not in sql.lower()
), "SQL should not use '::date' casting which prevents index usage"
assert (
"date(" not in sql.lower()
), "SQL should not use DATE() function which prevents index usage"
# 2) SQL should use timestamp-range filtering pattern for index optimization
assert '"startTime" >=' in sql or '"startTime">=' in sql, \
"SQL should use >= operator for lower bound"
assert '"startTime" <' in sql or '"startTime"<' in sql, \
"SQL should use < operator for upper bound"
assert "interval '1 day'" in sql.lower(), \
"SQL should use INTERVAL for date arithmetic"
assert (
'"startTime" >=' in sql or '"startTime">=' in sql
), "SQL should use >= operator for lower bound"
assert (
'"startTime" <' in sql or '"startTime"<' in sql
), "SQL should use < operator for upper bound"
assert (
"interval '1 day'" in sql.lower()
), "SQL should use INTERVAL for date arithmetic"
# 3) Parameters should be datetime objects (not date objects)
assert isinstance(params[0], datetime.datetime), \
"First parameter (start_date) should be datetime object"
assert isinstance(params[1], datetime.datetime), \
"Second parameter (end_date) should be datetime object"
assert params[0].tzinfo is not None, \
"start_date should be timezone-aware"
assert params[1].tzinfo is not None, \
"end_date should be timezone-aware"
assert isinstance(
params[0], datetime.datetime
), "First parameter (start_date) should be datetime object"
assert isinstance(
params[1], datetime.datetime
), "Second parameter (end_date) should be datetime object"
assert params[0].tzinfo is not None, "start_date should be timezone-aware"
assert params[1].tzinfo is not None, "end_date should be timezone-aware"
@pytest.mark.asyncio
async def test_global_activity_wraps_params_in_at_time_zone_utc(monkeypatch):
"""
/global/activity must emit `AT TIME ZONE 'UTC'` around its date params
so the date window and `date_trunc` bucketing do not depend on the DB
session timezone. Regression guard for Issue 1.
"""
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.spend_tracking.spend_management_endpoints import (
get_global_activity,
)
mock_prisma = MagicMock()
mock_prisma.db = MagicMock()
mock_prisma.db.query_raw = AsyncMock(return_value=[])
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin")
await get_global_activity(
start_date="2026-02-16",
end_date="2026-02-16",
user_api_key_dict=auth,
)
assert mock_prisma.db.query_raw.called, "query_raw should have been called"
call_args = mock_prisma.db.query_raw.call_args[0]
sql = call_args[0]
params = call_args[1:]
# 1) SQL must wrap both bounds in `AT TIME ZONE 'UTC'`.
assert sql.count("AT TIME ZONE 'UTC'") >= 2, (
"Both date bounds must be wrapped with `AT TIME ZONE 'UTC'` so that "
"comparison against the plain-timestamp column is session-TZ-independent. "
f"SQL was:\n{sql}"
)
# 2) Params must still be tz-aware UTC datetimes (preserves existing contract).
assert isinstance(params[0], datetime.datetime)
assert isinstance(params[1], datetime.datetime)
assert params[0].tzinfo is not None and params[0].utcoffset() == datetime.timedelta(
0
)
assert params[1].tzinfo is not None and params[1].utcoffset() == datetime.timedelta(
0
)
@pytest.mark.asyncio
async def test_global_activity_internal_user_wraps_params_in_at_time_zone_utc(
monkeypatch,
):
"""
The internal-user branch of /global/activity goes through a different
helper (`get_global_activity_internal_user`) and has its own SQL string.
Both branches must carry the fix. Regression guard for Issue 1.
"""
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.spend_tracking.spend_management_endpoints import (
get_global_activity,
)
mock_prisma = MagicMock()
mock_prisma.db = MagicMock()
mock_prisma.db.query_raw = AsyncMock(return_value=[])
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
auth = UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER, user_id="internal_user_1"
)
await get_global_activity(
start_date="2026-02-16",
end_date="2026-02-16",
user_api_key_dict=auth,
)
assert mock_prisma.db.query_raw.called
sql = mock_prisma.db.query_raw.call_args[0][0]
assert sql.count("AT TIME ZONE 'UTC'") >= 2, (
"Internal-user branch must also wrap date bounds with "
f"`AT TIME ZONE 'UTC'`. SQL was:\n{sql}"
)
@pytest.mark.asyncio
async def test_spend_logs_ui_wraps_params_in_at_time_zone_utc(monkeypatch):
"""
/spend/logs/ui builds its WHERE clause dynamically. The date-range
conditions must wrap the param side with `AT TIME ZONE 'UTC'` so the
log filter window doesn't drift with the DB session TZ. Regression
guard for GH #22529.
"""
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.spend_tracking.spend_management_endpoints import (
ui_view_spend_logs,
)
mock_prisma = MagicMock()
mock_prisma.db = MagicMock()
mock_prisma.db.query_raw = AsyncMock(return_value=[])
mock_prisma.db.litellm_spendlogs = MagicMock()
mock_prisma.db.litellm_spendlogs.count = AsyncMock(return_value=0)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin")
mock_request = MagicMock()
mock_request.url.path = "/spend/logs/ui"
await ui_view_spend_logs(
request=mock_request,
api_key=None,
user_id=None,
request_id=None,
start_date="2026-02-16 00:00:00",
end_date="2026-02-16 23:59:59",
page=1,
page_size=50,
sort_by="startTime",
sort_order="desc",
user_api_key_dict=auth,
)
assert mock_prisma.db.query_raw.called, "query_raw should have been called"
sql = mock_prisma.db.query_raw.call_args[0][0]
assert sql.count("AT TIME ZONE 'UTC'") >= 2, (
"/spend/logs/ui must wrap both `startTime` bounds with "
f"`AT TIME ZONE 'UTC'`. SQL was:\n{sql}"
)

View file

@ -971,3 +971,106 @@ class TestWebSocketChunkTypes:
)
assert len(messages) == 1
assert messages[0]["content"][0]["text"] == "Part 1Part 2"
class TestNativeWebSocketUrlConstruction:
"""Test that native WebSocket URLs include the model query parameter.
These tests mock websockets.connect so they exercise the actual URL-building
code inside BaseLLMHTTPHandler.async_responses_websocket rather than
reimplementing the logic themselves.
"""
@pytest.mark.asyncio
async def test_openai_ws_url_includes_model(self):
"""Handler must pass ?model= in the URL to the backend WebSocket."""
from unittest.mock import AsyncMock, MagicMock, patch
captured_urls = []
class FakeConnect:
def __init__(self, url, **kwargs):
captured_urls.append(url)
async def __aenter__(self):
raise Exception("stop")
async def __aexit__(self, *args):
pass
mock_config = MagicMock(spec=OpenAIResponsesAPIConfig)
mock_config.supports_native_websocket.return_value = True
mock_config.get_complete_url.return_value = "https://api.openai.com/v1/responses"
mock_config.validate_environment.return_value = {}
mock_logging = MagicMock()
mock_logging.pre_call = MagicMock()
from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler
handler = BaseLLMHTTPHandler()
mock_ws = MagicMock()
mock_ws.close = AsyncMock()
with patch("websockets.connect", FakeConnect):
await handler.async_responses_websocket(
model="gpt-4o-mini",
websocket=mock_ws,
logging_obj=mock_logging,
responses_api_provider_config=mock_config,
api_key="sk-test",
)
assert len(captured_urls) == 1
from urllib.parse import parse_qs, urlparse
qs = parse_qs(urlparse(captured_urls[0]).query)
assert qs.get("model") == ["gpt-4o-mini"], f"Expected model in URL, got: {captured_urls[0]}"
@pytest.mark.asyncio
async def test_ws_url_preserves_existing_params_and_adds_model(self):
"""When api_base already has query params, model is added alongside them."""
from unittest.mock import AsyncMock, MagicMock, patch
captured_urls = []
class FakeConnect:
def __init__(self, url, **kwargs):
captured_urls.append(url)
async def __aenter__(self):
raise Exception("stop")
async def __aexit__(self, *args):
pass
mock_config = MagicMock(spec=OpenAIResponsesAPIConfig)
mock_config.supports_native_websocket.return_value = True
mock_config.get_complete_url.return_value = (
"https://custom.example.com/v1/responses?api-version=2024-05-01"
)
mock_config.validate_environment.return_value = {}
mock_logging = MagicMock()
mock_logging.pre_call = MagicMock()
from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler
handler = BaseLLMHTTPHandler()
mock_ws = MagicMock()
mock_ws.close = AsyncMock()
with patch("websockets.connect", FakeConnect):
await handler.async_responses_websocket(
model="gpt-4o",
websocket=mock_ws,
logging_obj=mock_logging,
responses_api_provider_config=mock_config,
api_key="sk-test",
)
assert len(captured_urls) == 1
from urllib.parse import parse_qs, urlparse
qs = parse_qs(urlparse(captured_urls[0]).query)
assert qs.get("model") == ["gpt-4o"], f"model missing from URL: {captured_urls[0]}"
assert qs.get("api-version") == ["2024-05-01"], f"existing param lost: {captured_urls[0]}"

View file

@ -131,7 +131,7 @@ describe("ModelsAndEndpointsView", () => {
</QueryClientProvider>,
);
expect(await findByText("Model Management", {}, { timeout: 10000 })).toBeInTheDocument();
}, 15000);
});
it("should show Missing provider banner by default", async () => {
localStorageMock.clear();
@ -149,7 +149,7 @@ describe("ModelsAndEndpointsView", () => {
</QueryClientProvider>,
);
expect(await findByText("Missing a provider?", {}, { timeout: 10000 })).toBeInTheDocument();
}, 15000);
});
it("should hide Missing provider banner when dismiss button is clicked and persist to localStorage", async () => {
localStorageMock.clear();
@ -180,7 +180,7 @@ describe("ModelsAndEndpointsView", () => {
// LocalStorage should be updated
expect(localStorageMock.getItem("hideMissingProviderBanner")).toBe("true");
}, 15000);
});
it("should show compact Request Provider button when banner is dismissed", async () => {
// Set localStorage to hide banner
@ -209,7 +209,7 @@ describe("ModelsAndEndpointsView", () => {
const requestProviderLinks = document.querySelectorAll('a[href="https://models.litellm.ai/?request=true"]');
// There should be a compact button when banner is hidden
expect(requestProviderLinks.length).toBeGreaterThan(0);
}, 15000);
});
it("should pass model IDs (not model names) to HealthCheckComponent as all_models_on_proxy", async () => {
mockHealthCheckComponent.mockClear();

View file

@ -46,10 +46,17 @@ function LoginPageContent() {
// Cross-origin SSO: worker redirected back with a single-use code.
// Exchange it for the JWT via the worker's /v3/login/exchange endpoint.
const params = new URLSearchParams(window.location.search);
const ssoCode = params.get("code");
const rawSsoCode = params.get("code");
// Validate the SSO code is a plausible OAuth authorization code (alphanumeric
// plus common URL-safe chars) so that arbitrary user input cannot trigger the
// exchange endpoint.
const ssoCode =
rawSsoCode && /^[a-zA-Z0-9._~+/=-]+$/.test(rawSsoCode) ? rawSsoCode : null;
if (ssoCode) {
// codeql[js/user-controlled-bypass]
const workerUrl = localStorage.getItem("litellm_worker_url");
const rawWorkerUrl = localStorage.getItem("litellm_worker_url");
// Validate the stored worker URL: only allow http(s) URLs.
const workerUrl =
rawWorkerUrl && /^https?:\/\/.+/.test(rawWorkerUrl) ? rawWorkerUrl : null;
exchangeLoginCode(ssoCode, workerUrl).then(() => {
params.delete("code");
const cleanSearch = params.toString();

View file

@ -2,6 +2,7 @@
import { Suspense, useEffect, useMemo } from "react";
import { useSearchParams } from "next/navigation";
import { getSecureItem, setSecureItem } from "@/utils/secureStorage";
// Written to sessionStorage so both the admin hook (useMcpOAuthFlow) and the
// user hook (useUserMcpOAuthFlow) can pick up the result. Each hook reads
@ -52,14 +53,24 @@ const McpOAuthCallbackContent = () => {
// Write to both namespace keys (admin and user) so whichever hook is
// active can consume the result. sessionStorage only — no localStorage.
const serialized = JSON.stringify(payload);
window.sessionStorage.setItem(ADMIN_RESULT_KEY, serialized);
window.sessionStorage.setItem(USER_RESULT_KEY, serialized);
setSecureItem(ADMIN_RESULT_KEY, serialized);
setSecureItem(USER_RESULT_KEY, serialized);
} catch (err) {
// Silently ignore storage errors
}
const returnUrl = window.sessionStorage.getItem(RETURN_URL_STORAGE_KEY);
const destination = returnUrl || resolveDefaultRedirect();
const returnUrl = getSecureItem(RETURN_URL_STORAGE_KEY);
let destination = resolveDefaultRedirect();
if (returnUrl) {
try {
const parsed = new URL(returnUrl, window.location.origin);
if (parsed.origin === window.location.origin) {
destination = parsed.href;
}
} catch {
// invalid URL — fall through to default
}
}
window.location.replace(destination);
}, [payload]);

View file

@ -277,13 +277,18 @@ function CreateKeyPageContent() {
// Check for a stored return URL
const returnUrl = consumeReturnUrl();
if (returnUrl && isValidReturnUrl(returnUrl)) {
// Inline origin check: only redirect to same-origin URLs to prevent open redirect.
const safeUrl = new URL(returnUrl, window.location.origin);
if (safeUrl.origin !== window.location.origin) {
return;
}
const currentUrl = window.location.href;
const normalizedReturnUrl = normalizeUrlForCompare(returnUrl);
const normalizedCurrentUrl = normalizeUrlForCompare(currentUrl);
// Only redirect if the return URL is different from the current URL
// This prevents infinite redirect loops
if (normalizedReturnUrl !== normalizedCurrentUrl) {
window.location.replace(returnUrl);
window.location.replace(safeUrl.href);
}
}
}, [authLoading, token]);

View file

@ -51,7 +51,7 @@ function renderWithProviders(ui: React.ReactElement) {
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
}
describe("CreateUserButton", { timeout: 20000 }, () => {
describe("CreateUserButton", () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetProxyUISettings.mockResolvedValue({
@ -62,288 +62,296 @@ describe("CreateUserButton", { timeout: 20000 }, () => {
});
});
it("should render the create user form when embedded", () => {
renderWithProviders(
<CreateUserButton {...defaultProps} isEmbedded />,
);
expect(screen.getByRole("button", { name: /create user/i })).toBeInTheDocument();
});
describe("rendering and visibility", () => {
it("should render the create user form when embedded", () => {
renderWithProviders(
<CreateUserButton {...defaultProps} isEmbedded />,
);
expect(screen.getByRole("button", { name: /create user/i })).toBeInTheDocument();
});
it("should render the invite user button when not embedded", async () => {
renderWithProviders(<CreateUserButton {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: /\+ invite user/i })).toBeInTheDocument();
it("should render the invite user button when not embedded", async () => {
renderWithProviders(<CreateUserButton {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: /\+ invite user/i })).toBeInTheDocument();
});
});
it("should open the invite modal when invite user button is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(<CreateUserButton {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: /\+ invite user/i })).toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: /\+ invite user/i }));
const dialog = screen.getByRole("dialog", { name: /invite user/i });
expect(dialog).toBeInTheDocument();
expect(within(dialog).getByRole("button", { name: /invite user/i })).toBeInTheDocument();
});
it("should display email invitations info message in embedded mode", () => {
renderWithProviders(<CreateUserButton {...defaultProps} isEmbedded />);
expect(screen.getByText("Email invitations")).toBeInTheDocument();
});
it("should display user role options when possibleUIRoles is provided", async () => {
const possibleUIRoles = {
proxy_admin: { ui_label: "Admin", description: "Full access" },
proxy_user: { ui_label: "User", description: "Limited access" },
};
renderWithProviders(
<CreateUserButton {...defaultProps} possibleUIRoles={possibleUIRoles} isEmbedded />,
);
await userEvent.click(screen.getByRole("combobox", { name: /user role/i }));
expect(screen.getByText("Admin")).toBeInTheDocument();
expect(screen.getByText("User")).toBeInTheDocument();
});
it("should close modal when cancel is clicked in standalone mode", async () => {
const user = userEvent.setup();
renderWithProviders(<CreateUserButton {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: /\+ invite user/i })).toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: /\+ invite user/i }));
expect(screen.getByRole("dialog", { name: /invite user/i })).toBeInTheDocument();
const dialog = screen.getByRole("dialog", { name: /invite user/i });
await user.click(within(dialog).getByRole("button", { name: /close/i }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});
it("should open the invite modal when invite user button is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(<CreateUserButton {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: /\+ invite user/i })).toBeInTheDocument();
describe("embedded mode submission", () => {
it("should call userCreateCall when form is submitted in embedded mode", async () => {
const user = userEvent.setup();
mockUserCreateCall.mockResolvedValue({ data: { user_id: "new-user-123" } });
mockInvitationCreateCall.mockResolvedValue({
id: "inv-1",
user_id: "new-user-123",
has_user_setup_sso: false,
} as any);
renderWithProviders(
<CreateUserButton {...defaultProps} possibleUIRoles={{ proxy_user: { ui_label: "User", description: "" } }} isEmbedded />,
);
await user.type(screen.getByLabelText(/user email/i), "test@example.com");
await user.click(screen.getByRole("combobox", { name: /user role/i }));
await user.click(screen.getByText("User"));
await user.click(screen.getByRole("button", { name: /create user/i }));
await waitFor(() => {
expect(mockUserCreateCall).toHaveBeenCalledWith("token", null, expect.objectContaining({
user_email: "test@example.com",
user_role: "proxy_user",
}));
});
});
await user.click(screen.getByRole("button", { name: /\+ invite user/i }));
const dialog = screen.getByRole("dialog", { name: /invite user/i });
expect(dialog).toBeInTheDocument();
expect(within(dialog).getByRole("button", { name: /invite user/i })).toBeInTheDocument();
});
it("should display email invitations info message in embedded mode", () => {
renderWithProviders(<CreateUserButton {...defaultProps} isEmbedded />);
expect(screen.getByText("Email invitations")).toBeInTheDocument();
});
it("should call onUserCreated callback when user is created in embedded mode", async () => {
const user = userEvent.setup();
const onUserCreated = vi.fn();
mockUserCreateCall.mockResolvedValue({ data: { user_id: "new-user-456" } });
it("should display user role options when possibleUIRoles is provided", async () => {
const possibleUIRoles = {
proxy_admin: { ui_label: "Admin", description: "Full access" },
proxy_user: { ui_label: "User", description: "Limited access" },
};
renderWithProviders(
<CreateUserButton {...defaultProps} possibleUIRoles={possibleUIRoles} isEmbedded />,
);
await userEvent.click(screen.getByRole("combobox", { name: /user role/i }));
expect(screen.getByText("Admin")).toBeInTheDocument();
expect(screen.getByText("User")).toBeInTheDocument();
});
renderWithProviders(
<CreateUserButton {...defaultProps} onUserCreated={onUserCreated} possibleUIRoles={{ proxy_user: { ui_label: "User", description: "" } }} isEmbedded />,
);
it("should call userCreateCall when form is submitted in embedded mode", async () => {
const user = userEvent.setup();
mockUserCreateCall.mockResolvedValue({ data: { user_id: "new-user-123" } });
mockInvitationCreateCall.mockResolvedValue({
id: "inv-1",
user_id: "new-user-123",
has_user_setup_sso: false,
} as any);
await user.type(screen.getByLabelText(/user email/i), "embedded@example.com");
await user.click(screen.getByRole("combobox", { name: /user role/i }));
await user.click(screen.getByText("User"));
await user.click(screen.getByRole("button", { name: /create user/i }));
renderWithProviders(
<CreateUserButton {...defaultProps} possibleUIRoles={{ proxy_user: { ui_label: "User", description: "" } }} isEmbedded />,
);
await waitFor(() => {
expect(onUserCreated).toHaveBeenCalledWith("new-user-456");
});
});
await user.type(screen.getByLabelText(/user email/i), "test@example.com");
await user.click(screen.getByRole("combobox", { name: /user role/i }));
await user.click(screen.getByText("User"));
await user.click(screen.getByRole("button", { name: /create user/i }));
it("should show error notification when user creation fails", async () => {
const user = userEvent.setup();
mockUserCreateCall.mockRejectedValue({ response: { data: { detail: "Email already exists" } } });
await waitFor(() => {
expect(mockUserCreateCall).toHaveBeenCalledWith("token", null, expect.objectContaining({
user_email: "test@example.com",
user_role: "proxy_user",
}));
renderWithProviders(
<CreateUserButton {...defaultProps} possibleUIRoles={{ proxy_user: { ui_label: "User", description: "" } }} isEmbedded />,
);
await user.type(screen.getByLabelText(/user email/i), "duplicate@example.com");
await user.click(screen.getByRole("combobox", { name: /user role/i }));
await user.click(screen.getByText("User"));
await user.click(screen.getByRole("button", { name: /create user/i }));
await waitFor(() => {
expect(mockNotificationsManager.fromBackend).toHaveBeenCalledWith("Email already exists");
});
});
it("should show info notification when making API call", async () => {
const user = userEvent.setup();
mockUserCreateCall.mockResolvedValue({ data: { user_id: "new-user" } });
mockInvitationCreateCall.mockResolvedValue({
id: "inv-3",
user_id: "new-user",
has_user_setup_sso: false,
} as any);
renderWithProviders(
<CreateUserButton {...defaultProps} possibleUIRoles={{ proxy_user: { ui_label: "User", description: "" } }} isEmbedded />,
);
await user.type(screen.getByLabelText(/user email/i), "info@example.com");
await user.click(screen.getByRole("combobox", { name: /user role/i }));
await user.click(screen.getByText("User"));
await user.click(screen.getByRole("button", { name: /create user/i }));
await waitFor(() => {
expect(mockNotificationsManager.info).toHaveBeenCalledWith("Making API Call");
});
});
});
it("should call onUserCreated callback when user is created in embedded mode", async () => {
const user = userEvent.setup();
const onUserCreated = vi.fn();
mockUserCreateCall.mockResolvedValue({ data: { user_id: "new-user-456" } });
describe("standalone mode submission", () => {
it("should show success notification when user is created successfully in standalone mode", async () => {
const user = userEvent.setup();
mockUserCreateCall.mockResolvedValue({ data: { user_id: "new-user-789" } });
mockInvitationCreateCall.mockResolvedValue({
id: "inv-2",
user_id: "new-user-789",
has_user_setup_sso: false,
} as any);
renderWithProviders(
<CreateUserButton {...defaultProps} onUserCreated={onUserCreated} possibleUIRoles={{ proxy_user: { ui_label: "User", description: "" } }} isEmbedded />,
);
renderWithProviders(
<CreateUserButton {...defaultProps} possibleUIRoles={{ proxy_user: { ui_label: "User", description: "" } }} />,
);
await user.type(screen.getByLabelText(/user email/i), "embedded@example.com");
await user.click(screen.getByRole("combobox", { name: /user role/i }));
await user.click(screen.getByText("User"));
await user.click(screen.getByRole("button", { name: /create user/i }));
await waitFor(() => {
expect(screen.getByRole("button", { name: /\+ invite user/i })).toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: /\+ invite user/i }));
await waitFor(() => {
expect(onUserCreated).toHaveBeenCalledWith("new-user-456");
const dialog = screen.getByRole("dialog", { name: /invite user/i });
await user.type(within(dialog).getByLabelText(/user email/i), "standalone@example.com");
await user.click(within(dialog).getByRole("combobox", { name: /global proxy role/i }));
await user.click(screen.getByText("User"));
await user.click(within(dialog).getByRole("button", { name: /invite user/i }));
await waitFor(() => {
expect(mockNotificationsManager.success).toHaveBeenCalledWith("API user Created");
});
});
it("should show onboarding modal when user is created and SSO is disabled", async () => {
const user = userEvent.setup();
mockUserCreateCall.mockResolvedValue({ data: { user_id: "sso-user" } });
mockInvitationCreateCall.mockResolvedValue({
id: "inv-sso",
user_id: "sso-user",
has_user_setup_sso: false,
} as any);
renderWithProviders(
<CreateUserButton {...defaultProps} possibleUIRoles={{ proxy_user: { ui_label: "User", description: "" } }} />,
);
await waitFor(() => {
expect(screen.getByRole("button", { name: /\+ invite user/i })).toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: /\+ invite user/i }));
const dialog = screen.getByRole("dialog", { name: /invite user/i });
await user.type(within(dialog).getByLabelText(/user email/i), "sso@example.com");
await user.click(within(dialog).getByRole("combobox", { name: /global proxy role/i }));
await user.click(screen.getByText("User"));
await user.click(within(dialog).getByRole("button", { name: /invite user/i }));
await waitFor(() => {
expect(mockInvitationCreateCall).toHaveBeenCalledWith("token", "sso-user");
});
await waitFor(() => {
expect(mockNotificationsManager.success).toHaveBeenCalledWith("API user Created");
});
});
});
it("should show success notification when user is created successfully in standalone mode", async () => {
const user = userEvent.setup();
mockUserCreateCall.mockResolvedValue({ data: { user_id: "new-user-789" } });
mockInvitationCreateCall.mockResolvedValue({
id: "inv-2",
user_id: "new-user-789",
has_user_setup_sso: false,
} as any);
describe("organizations", () => {
it("should send organizations list in POST body when organizations are selected", async () => {
const { useOrganizations } = await import("@/app/(dashboard)/hooks/organizations/useOrganizations");
vi.mocked(useOrganizations).mockReturnValue({
data: [{ organization_id: "org-1", organization_alias: "My Org" }],
isLoading: false,
} as any);
renderWithProviders(
<CreateUserButton {...defaultProps} possibleUIRoles={{ proxy_user: { ui_label: "User", description: "" } }} />,
);
const user = userEvent.setup();
mockUserCreateCall.mockResolvedValue({ data: { user_id: "org-user" } });
mockInvitationCreateCall.mockResolvedValue({
id: "inv-org",
user_id: "org-user",
has_user_setup_sso: false,
} as any);
await waitFor(() => {
expect(screen.getByRole("button", { name: /\+ invite user/i })).toBeInTheDocument();
renderWithProviders(
<CreateUserButton {...defaultProps} possibleUIRoles={{ proxy_user: { ui_label: "User", description: "" } }} />,
);
await waitFor(() => {
expect(screen.getByRole("button", { name: /\+ invite user/i })).toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: /\+ invite user/i }));
const dialog = screen.getByRole("dialog", { name: /invite user/i });
await user.type(within(dialog).getByLabelText(/user email/i), "org@example.com");
await user.click(within(dialog).getByRole("combobox", { name: /global proxy role/i }));
await user.click(screen.getByText("User"));
// Select org from the dropdown
const orgSelect = within(dialog).getByRole("combobox", { name: /organization/i });
await user.click(orgSelect);
await user.click(screen.getByText("My Org (org-1)"));
await user.click(within(dialog).getByRole("button", { name: /invite user/i }));
await waitFor(() => {
expect(mockUserCreateCall).toHaveBeenCalledWith("token", null, expect.objectContaining({
organizations: ["org-1"],
}));
});
});
await user.click(screen.getByRole("button", { name: /\+ invite user/i }));
const dialog = screen.getByRole("dialog", { name: /invite user/i });
await user.type(within(dialog).getByLabelText(/user email/i), "standalone@example.com");
await user.click(within(dialog).getByRole("combobox", { name: /global proxy role/i }));
await user.click(screen.getByText("User"));
await user.click(within(dialog).getByRole("button", { name: /invite user/i }));
it("should not call organizationMemberAddCall after user creation", async () => {
const { useOrganizations } = await import("@/app/(dashboard)/hooks/organizations/useOrganizations");
vi.mocked(useOrganizations).mockReturnValue({
data: [{ organization_id: "org-1", organization_alias: "My Org" }],
isLoading: false,
} as any);
await waitFor(() => {
expect(mockNotificationsManager.success).toHaveBeenCalledWith("API user Created");
const user = userEvent.setup();
mockUserCreateCall.mockResolvedValue({ data: { user_id: "no-member-add-user" } });
mockInvitationCreateCall.mockResolvedValue({
id: "inv-nma",
user_id: "no-member-add-user",
has_user_setup_sso: false,
} as any);
renderWithProviders(
<CreateUserButton {...defaultProps} possibleUIRoles={{ proxy_user: { ui_label: "User", description: "" } }} />,
);
await waitFor(() => {
expect(screen.getByRole("button", { name: /\+ invite user/i })).toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: /\+ invite user/i }));
const dialog = screen.getByRole("dialog", { name: /invite user/i });
await user.type(within(dialog).getByLabelText(/user email/i), "nomemberadd@example.com");
await user.click(within(dialog).getByRole("combobox", { name: /global proxy role/i }));
await user.click(screen.getByText("User"));
await user.click(within(dialog).getByRole("button", { name: /invite user/i }));
await waitFor(() => {
expect(mockUserCreateCall).toHaveBeenCalled();
});
expect(mockOrganizationMemberAddCall).not.toHaveBeenCalled();
});
});
it("should show error notification when user creation fails", async () => {
const user = userEvent.setup();
mockUserCreateCall.mockRejectedValue({ response: { data: { detail: "Email already exists" } } });
renderWithProviders(
<CreateUserButton {...defaultProps} possibleUIRoles={{ proxy_user: { ui_label: "User", description: "" } }} isEmbedded />,
);
await user.type(screen.getByLabelText(/user email/i), "duplicate@example.com");
await user.click(screen.getByRole("combobox", { name: /user role/i }));
await user.click(screen.getByText("User"));
await user.click(screen.getByRole("button", { name: /create user/i }));
await waitFor(() => {
expect(mockNotificationsManager.fromBackend).toHaveBeenCalledWith("Email already exists");
});
});
it("should show info notification when making API call", async () => {
const user = userEvent.setup();
mockUserCreateCall.mockResolvedValue({ data: { user_id: "new-user" } });
mockInvitationCreateCall.mockResolvedValue({
id: "inv-3",
user_id: "new-user",
has_user_setup_sso: false,
} as any);
renderWithProviders(
<CreateUserButton {...defaultProps} possibleUIRoles={{ proxy_user: { ui_label: "User", description: "" } }} isEmbedded />,
);
await user.type(screen.getByLabelText(/user email/i), "info@example.com");
await user.click(screen.getByRole("combobox", { name: /user role/i }));
await user.click(screen.getByText("User"));
await user.click(screen.getByRole("button", { name: /create user/i }));
await waitFor(() => {
expect(mockNotificationsManager.info).toHaveBeenCalledWith("Making API Call");
});
});
it("should close modal when cancel is clicked in standalone mode", async () => {
const user = userEvent.setup();
renderWithProviders(<CreateUserButton {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: /\+ invite user/i })).toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: /\+ invite user/i }));
expect(screen.getByRole("dialog", { name: /invite user/i })).toBeInTheDocument();
const dialog = screen.getByRole("dialog", { name: /invite user/i });
await user.click(within(dialog).getByRole("button", { name: /close/i }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("should show onboarding modal when user is created and SSO is disabled", async () => {
const user = userEvent.setup();
mockUserCreateCall.mockResolvedValue({ data: { user_id: "sso-user" } });
mockInvitationCreateCall.mockResolvedValue({
id: "inv-sso",
user_id: "sso-user",
has_user_setup_sso: false,
} as any);
renderWithProviders(
<CreateUserButton {...defaultProps} possibleUIRoles={{ proxy_user: { ui_label: "User", description: "" } }} />,
);
await waitFor(() => {
expect(screen.getByRole("button", { name: /\+ invite user/i })).toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: /\+ invite user/i }));
const dialog = screen.getByRole("dialog", { name: /invite user/i });
await user.type(within(dialog).getByLabelText(/user email/i), "sso@example.com");
await user.click(within(dialog).getByRole("combobox", { name: /global proxy role/i }));
await user.click(screen.getByText("User"));
await user.click(within(dialog).getByRole("button", { name: /invite user/i }));
await waitFor(() => {
expect(mockInvitationCreateCall).toHaveBeenCalledWith("token", "sso-user");
});
await waitFor(() => {
expect(mockNotificationsManager.success).toHaveBeenCalledWith("API user Created");
});
});
it("should send organizations list in POST body when organizations are selected", async () => {
const { useOrganizations } = await import("@/app/(dashboard)/hooks/organizations/useOrganizations");
vi.mocked(useOrganizations).mockReturnValue({
data: [{ organization_id: "org-1", organization_alias: "My Org" }],
isLoading: false,
} as any);
const user = userEvent.setup();
mockUserCreateCall.mockResolvedValue({ data: { user_id: "org-user" } });
mockInvitationCreateCall.mockResolvedValue({
id: "inv-org",
user_id: "org-user",
has_user_setup_sso: false,
} as any);
renderWithProviders(
<CreateUserButton {...defaultProps} possibleUIRoles={{ proxy_user: { ui_label: "User", description: "" } }} />,
);
await waitFor(() => {
expect(screen.getByRole("button", { name: /\+ invite user/i })).toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: /\+ invite user/i }));
const dialog = screen.getByRole("dialog", { name: /invite user/i });
await user.type(within(dialog).getByLabelText(/user email/i), "org@example.com");
await user.click(within(dialog).getByRole("combobox", { name: /global proxy role/i }));
await user.click(screen.getByText("User"));
// Select org from the dropdown
const orgSelect = within(dialog).getByRole("combobox", { name: /organization/i });
await user.click(orgSelect);
await user.click(screen.getByText("My Org (org-1)"));
await user.click(within(dialog).getByRole("button", { name: /invite user/i }));
await waitFor(() => {
expect(mockUserCreateCall).toHaveBeenCalledWith("token", null, expect.objectContaining({
organizations: ["org-1"],
}));
});
});
it("should not call organizationMemberAddCall after user creation", async () => {
const { useOrganizations } = await import("@/app/(dashboard)/hooks/organizations/useOrganizations");
vi.mocked(useOrganizations).mockReturnValue({
data: [{ organization_id: "org-1", organization_alias: "My Org" }],
isLoading: false,
} as any);
const user = userEvent.setup();
mockUserCreateCall.mockResolvedValue({ data: { user_id: "no-member-add-user" } });
mockInvitationCreateCall.mockResolvedValue({
id: "inv-nma",
user_id: "no-member-add-user",
has_user_setup_sso: false,
} as any);
renderWithProviders(
<CreateUserButton {...defaultProps} possibleUIRoles={{ proxy_user: { ui_label: "User", description: "" } }} />,
);
await waitFor(() => {
expect(screen.getByRole("button", { name: /\+ invite user/i })).toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: /\+ invite user/i }));
const dialog = screen.getByRole("dialog", { name: /invite user/i });
await user.type(within(dialog).getByLabelText(/user email/i), "nomemberadd@example.com");
await user.click(within(dialog).getByRole("combobox", { name: /global proxy role/i }));
await user.click(screen.getByText("User"));
await user.click(within(dialog).getByRole("button", { name: /invite user/i }));
await waitFor(() => {
expect(mockUserCreateCall).toHaveBeenCalled();
});
expect(mockOrganizationMemberAddCall).not.toHaveBeenCalled();
});
});

View file

@ -843,7 +843,7 @@ describe("OldTeams - access_group_ids in team create", () => {
}),
);
});
}, { timeout: 30000 });
});
});
describe("OldTeams - models dropdown options", () => {

View file

@ -175,7 +175,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();
@ -269,7 +269,7 @@ describe("Add Model Tab", () => {
},
{ timeout: 10000 },
);
}, 15000); // 15 second timeout to allow waitFor to complete
});
it("should show team selection when team-only switch is enabled", async () => {
const props = createTestProps();

View file

@ -150,151 +150,139 @@ describe("CreateMCPServer", () => {
});
});
it(
"should not require auth value when creating a server with API Key auth type",
{ timeout: 15000 },
async () => {
await selectHttpTransport();
it("should not require auth value when creating a server with API Key auth type", async () => {
await selectHttpTransport();
const user = userEvent.setup({ delay: null });
const user = userEvent.setup({ delay: null });
// Fill in server name (use id to avoid duplicate placeholder)
const nameInput = getServerNameInput();
await user.type(nameInput, "Test_Server");
// Fill in server name (use id to avoid duplicate placeholder)
const nameInput = getServerNameInput();
await user.type(nameInput, "Test_Server");
// Fill in URL
const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com");
await user.type(urlInput, "https://example.com/mcp");
// Fill in URL
const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com");
await user.type(urlInput, "https://example.com/mcp");
// Select API Key auth type
await selectAntOption("Authentication", "API Key");
// Select API Key auth type
await selectAntOption("Authentication", "API Key");
await waitFor(() => {
expect(screen.getByText("Authentication Value")).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByText("Authentication Value")).toBeInTheDocument();
});
// Leave auth value empty and submit
vi.mocked(networking.createMCPServer).mockResolvedValue({
server_id: "new-server-1",
server_name: "Test_Server",
alias: "Test_Server",
url: "https://example.com/mcp",
transport: "http",
auth_type: "api_key",
created_at: "2024-01-01T00:00:00Z",
created_by: "user-1",
updated_at: "2024-01-01T00:00:00Z",
updated_by: "user-1",
});
// Leave auth value empty and submit
vi.mocked(networking.createMCPServer).mockResolvedValue({
server_id: "new-server-1",
server_name: "Test_Server",
alias: "Test_Server",
url: "https://example.com/mcp",
transport: "http",
auth_type: "api_key",
created_at: "2024-01-01T00:00:00Z",
created_by: "user-1",
updated_at: "2024-01-01T00:00:00Z",
updated_by: "user-1",
});
const submitButton = screen.getByRole("button", { name: "Add MCP Server" });
await act(async () => {
fireEvent.click(submitButton);
});
const submitButton = screen.getByRole("button", { name: "Add MCP Server" });
await act(async () => {
fireEvent.click(submitButton);
});
// The form should submit without validation error on auth_value
await waitFor(() => {
expect(networking.createMCPServer).toHaveBeenCalledTimes(1);
});
},
);
// The form should submit without validation error on auth_value
await waitFor(() => {
expect(networking.createMCPServer).toHaveBeenCalledTimes(1);
});
});
it(
"should not require auth value when creating a server with Bearer Token auth type",
{ timeout: 15000 },
async () => {
await selectHttpTransport();
it("should not require auth value when creating a server with Bearer Token auth type", async () => {
await selectHttpTransport();
const user = userEvent.setup({ delay: null });
const user = userEvent.setup({ delay: null });
const nameInput = getServerNameInput();
await user.type(nameInput, "Test_Server");
const nameInput = getServerNameInput();
await user.type(nameInput, "Test_Server");
const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com");
await user.type(urlInput, "https://example.com/mcp");
const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com");
await user.type(urlInput, "https://example.com/mcp");
await selectAntOption("Authentication", "Bearer Token");
await selectAntOption("Authentication", "Bearer Token");
await waitFor(() => {
expect(screen.getByText("Authentication Value")).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByText("Authentication Value")).toBeInTheDocument();
});
// Leave auth value empty and submit
vi.mocked(networking.createMCPServer).mockResolvedValue({
server_id: "new-server-1",
server_name: "Test_Server",
alias: "Test_Server",
url: "https://example.com/mcp",
transport: "http",
auth_type: "bearer_token",
created_at: "2024-01-01T00:00:00Z",
created_by: "user-1",
updated_at: "2024-01-01T00:00:00Z",
updated_by: "user-1",
});
// Leave auth value empty and submit
vi.mocked(networking.createMCPServer).mockResolvedValue({
server_id: "new-server-1",
server_name: "Test_Server",
alias: "Test_Server",
url: "https://example.com/mcp",
transport: "http",
auth_type: "bearer_token",
created_at: "2024-01-01T00:00:00Z",
created_by: "user-1",
updated_at: "2024-01-01T00:00:00Z",
updated_by: "user-1",
});
const submitButton = screen.getByRole("button", { name: "Add MCP Server" });
await act(async () => {
fireEvent.click(submitButton);
});
const submitButton = screen.getByRole("button", { name: "Add MCP Server" });
await act(async () => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(networking.createMCPServer).toHaveBeenCalledTimes(1);
});
},
);
await waitFor(() => {
expect(networking.createMCPServer).toHaveBeenCalledTimes(1);
});
});
it(
"should successfully create a server when auth value is provided",
{ timeout: 15000 },
async () => {
await selectHttpTransport();
it("should successfully create a server when auth value is provided", async () => {
await selectHttpTransport();
const user = userEvent.setup({ delay: null });
const user = userEvent.setup({ delay: null });
const nameInput = getServerNameInput();
await user.type(nameInput, "My_Server");
const nameInput = getServerNameInput();
await user.type(nameInput, "My_Server");
const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com");
await user.type(urlInput, "https://example.com/mcp");
const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com");
await user.type(urlInput, "https://example.com/mcp");
await selectAntOption("Authentication", "API Key");
await selectAntOption("Authentication", "API Key");
await waitFor(() => {
expect(screen.getByText("Authentication Value")).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByText("Authentication Value")).toBeInTheDocument();
});
// Fill in auth value
const authInput = screen.getByPlaceholderText("Enter token or secret");
await user.type(authInput, "my-secret-key");
// Fill in auth value
const authInput = screen.getByPlaceholderText("Enter token or secret");
await user.type(authInput, "my-secret-key");
vi.mocked(networking.createMCPServer).mockResolvedValue({
server_id: "new-server-1",
server_name: "My_Server",
alias: "My_Server",
url: "https://example.com/mcp",
transport: "http",
auth_type: "api_key",
created_at: "2024-01-01T00:00:00Z",
created_by: "user-1",
updated_at: "2024-01-01T00:00:00Z",
updated_by: "user-1",
});
vi.mocked(networking.createMCPServer).mockResolvedValue({
server_id: "new-server-1",
server_name: "My_Server",
alias: "My_Server",
url: "https://example.com/mcp",
transport: "http",
auth_type: "api_key",
created_at: "2024-01-01T00:00:00Z",
created_by: "user-1",
updated_at: "2024-01-01T00:00:00Z",
updated_by: "user-1",
});
const submitButton = screen.getByRole("button", { name: "Add MCP Server" });
await act(async () => {
fireEvent.click(submitButton);
});
const submitButton = screen.getByRole("button", { name: "Add MCP Server" });
await act(async () => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(networking.createMCPServer).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(networking.createMCPServer).toHaveBeenCalledTimes(1);
});
const [token, payload] = vi.mocked(networking.createMCPServer).mock.calls[0];
expect(token).toBe("test-token");
expect(payload.credentials).toEqual({ auth_value: "my-secret-key" });
},
);
const [token, payload] = vi.mocked(networking.createMCPServer).mock.calls[0];
expect(token).toBe("test-token");
expect(payload.credentials).toEqual({ auth_value: "my-secret-key" });
});
it("should not show auth value field when None auth type is selected", async () => {
await selectHttpTransport();
@ -307,50 +295,46 @@ describe("CreateMCPServer", () => {
});
});
it(
"should successfully create a server with no auth",
{ timeout: 15000 },
async () => {
await selectHttpTransport();
it("should successfully create a server with no auth", async () => {
await selectHttpTransport();
const user = userEvent.setup({ delay: null });
const user = userEvent.setup({ delay: null });
const nameInput = getServerNameInput();
await user.type(nameInput, "No_Auth_Server");
const nameInput = getServerNameInput();
await user.type(nameInput, "No_Auth_Server");
const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com");
await user.type(urlInput, "https://example.com/mcp");
const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com");
await user.type(urlInput, "https://example.com/mcp");
await selectAntOption("Authentication", "None");
await selectAntOption("Authentication", "None");
vi.mocked(networking.createMCPServer).mockResolvedValue({
server_id: "new-server-1",
server_name: "No_Auth_Server",
alias: "No_Auth_Server",
url: "https://example.com/mcp",
transport: "http",
auth_type: "none",
created_at: "2024-01-01T00:00:00Z",
created_by: "user-1",
updated_at: "2024-01-01T00:00:00Z",
updated_by: "user-1",
});
vi.mocked(networking.createMCPServer).mockResolvedValue({
server_id: "new-server-1",
server_name: "No_Auth_Server",
alias: "No_Auth_Server",
url: "https://example.com/mcp",
transport: "http",
auth_type: "none",
created_at: "2024-01-01T00:00:00Z",
created_by: "user-1",
updated_at: "2024-01-01T00:00:00Z",
updated_by: "user-1",
});
const submitButton = screen.getByRole("button", { name: "Add MCP Server" });
await act(async () => {
fireEvent.click(submitButton);
});
const submitButton = screen.getByRole("button", { name: "Add MCP Server" });
await act(async () => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(networking.createMCPServer).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(networking.createMCPServer).toHaveBeenCalledTimes(1);
});
const [, payload] = vi.mocked(networking.createMCPServer).mock.calls[0];
expect(payload.auth_type).toBe("none");
// No credentials should be sent for "none" auth
expect(payload.credentials).toBeUndefined();
},
);
const [, payload] = vi.mocked(networking.createMCPServer).mock.calls[0];
expect(payload.auth_type).toBe("none");
// No credentials should be sent for "none" auth
expect(payload.credentials).toBeUndefined();
});
});
describe("when OAuth interactive auth is selected", () => {

View file

@ -17,6 +17,7 @@ import { validateMCPServerUrl, validateMCPServerName } from "./utils";
import NotificationsManager from "../molecules/notifications_manager";
import { useMcpOAuthFlow } from "@/hooks/useMcpOAuthFlow";
import { useTestMCPConnection } from "@/hooks/useTestMCPConnection";
import { getSecureItem, setSecureItem } from "@/utils/secureStorage";
const asset_logos_folder = "../ui/assets/logos/";
export const mcpLogoImg = `${asset_logos_folder}mcp_logo.png`;
@ -94,8 +95,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
}
try {
const values = form.getFieldsValue(true);
// codeql[js/clear-text-storage-of-sensitive-data]
window.sessionStorage.setItem(
setSecureItem(
CREATE_OAUTH_UI_STATE_KEY,
JSON.stringify({
modalVisible: isModalVisible,
@ -178,7 +178,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
if (typeof window === "undefined") {
return;
}
const storedState = window.sessionStorage.getItem(CREATE_OAUTH_UI_STATE_KEY);
const storedState = getSecureItem(CREATE_OAUTH_UI_STATE_KEY);
if (!storedState) {
return;
}

View file

@ -12,6 +12,7 @@ import MCPLogoSelector from "./MCPLogoSelector";
import { validateMCPServerUrl, validateMCPServerName } from "./utils";
import NotificationsManager from "../molecules/notifications_manager";
import { useMcpOAuthFlow } from "@/hooks/useMcpOAuthFlow";
import { getSecureItem, setSecureItem } from "@/utils/secureStorage";
interface MCPServerEditProps {
mcpServer: MCPServer;
@ -73,8 +74,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
}
try {
const values = form.getFieldsValue(true);
// codeql[js/clear-text-storage-of-sensitive-data]
window.sessionStorage.setItem(
setSecureItem(
EDIT_OAUTH_UI_STATE_KEY,
JSON.stringify({
serverId: mcpServer.server_id,
@ -217,7 +217,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
if (typeof window === "undefined") {
return;
}
const storedState = window.sessionStorage.getItem(EDIT_OAUTH_UI_STATE_KEY);
const storedState = getSecureItem(EDIT_OAUTH_UI_STATE_KEY);
if (!storedState) {
return;
}

View file

@ -20,6 +20,7 @@ import MCPSemanticFilterSettings from "../Settings/AdminSettings/MCPSemanticFilt
import MCPNetworkSettings from "./MCPNetworkSettings";
import MCPDiscovery from "./mcp_discovery";
import { ByokCredentialModal } from "./ByokCredentialModal";
import { getSecureItem } from "@/utils/secureStorage";
const { Text: AntdText, Title: AntdTitle } = Typography;
const EDIT_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-edit-state";
@ -70,7 +71,7 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
return;
}
try {
const stored = window.sessionStorage.getItem(EDIT_OAUTH_UI_STATE_KEY);
const stored = getSecureItem(EDIT_OAUTH_UI_STATE_KEY);
if (!stored) {
return;
}

View file

@ -75,6 +75,7 @@ import RealtimePlayground from "./RealtimePlayground";
import { A2ATaskMetadata, MessageType } from "./types";
import { useCodeInterpreter } from "./useCodeInterpreter";
import { useChatHistory } from "./useChatHistory";
import { getSecureItem, setSecureItem } from "@/utils/secureStorage";
const { TextArea } = Input;
const { Dragger } = Upload;
@ -167,7 +168,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
} = useChatHistory({ simplified });
// codeql[js/clear-text-storage-of-sensitive-data]
const [apiKeySource, setApiKeySource] = useState<"session" | "custom">(() => {
const saved = sessionStorage.getItem("apiKeySource");
const saved = getSecureItem("apiKeySource");
if (saved) {
try {
return JSON.parse(saved) as "session" | "custom";
@ -177,8 +178,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
}
return disabledPersonalKeyCreation ? "custom" : "session";
});
// codeql[js/clear-text-storage-of-sensitive-data]
const [apiKey, setApiKey] = useState<string>(() => sessionStorage.getItem("apiKey") || "");
const [apiKey, setApiKey] = useState<string>(() => getSecureItem("apiKey") || "");
const [customProxyBaseUrl, setCustomProxyBaseUrl] = useState<string>(
() => sessionStorage.getItem("customProxyBaseUrl") || "",
);
@ -348,10 +348,12 @@ const ChatUI: React.FC<ChatUIProps> = ({
]);
useEffect(() => {
// codeql[js/clear-text-storage-of-sensitive-data]
sessionStorage.setItem("apiKeySource", JSON.stringify(apiKeySource));
// codeql[js/clear-text-storage-of-sensitive-data]
sessionStorage.setItem("apiKey", apiKey);
try {
setSecureItem("apiKeySource", JSON.stringify(apiKeySource));
setSecureItem("apiKey", apiKey);
} catch {
// Storage full or unavailable — non-critical, skip persisting.
}
sessionStorage.setItem("endpointType", endpointType);
sessionStorage.setItem("selectedTags", JSON.stringify(selectedTags));
sessionStorage.setItem("selectedVectorStores", JSON.stringify(selectedVectorStores));
@ -502,7 +504,9 @@ const ChatUI: React.FC<ChatUIProps> = ({
const handleImageUpload = (file: File) => {
setUploadedImages((prev) => [...prev, file]);
const previewUrl = URL.createObjectURL(file);
const rawPreviewUrl = URL.createObjectURL(file);
// Sanitize: only allow blob: URLs to prevent XSS via img src injection.
const previewUrl = rawPreviewUrl.startsWith("blob:") ? rawPreviewUrl : "";
setImagePreviewUrls((prev) => [...prev, previewUrl]);
return false; // Prevent default upload behavior
};
@ -1827,7 +1831,16 @@ const ChatUI: React.FC<ChatUIProps> = ({
{uploadedImages.map((file, index) => (
<div key={index} className="relative inline-block">
<img
src={imagePreviewUrls[index] || ""}
src={(() => {
const url = imagePreviewUrls[index];
if (!url) return "";
try {
const parsed = new URL(url);
return parsed.protocol === "blob:" ? parsed.href : "";
} catch {
return "";
}
})()}
alt={`Upload preview ${index + 1}`}
className="max-w-32 max-h-32 rounded-md border border-gray-200 object-cover"
/>

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
import { screen, waitFor } from "@testing-library/react";
import { cleanup, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../../tests/test-utils";
import { UserEditView } from "./user_edit_view";
@ -140,6 +140,15 @@ describe("UserEditView", () => {
vi.clearAllMocks();
});
afterEach(() => {
// Tremor's internal Tooltip sets a setTimeout that fires after teardown,
// causing "window is not defined". Flush pending timers before cleanup.
vi.useFakeTimers();
vi.runAllTimers();
vi.useRealTimers();
cleanup();
});
it("should render", async () => {
renderWithProviders(<UserEditView {...defaultProps} />);

View file

@ -11,6 +11,7 @@ import {
serverRootPath,
} from "@/components/networking";
import { extractErrorMessage } from "@/utils/errorUtils";
import { getSecureItem, setSecureItem } from "@/utils/secureStorage";
export type McpOAuthStatus = "idle" | "authorizing" | "exchanging" | "success" | "error";
@ -79,22 +80,13 @@ export const useMcpOAuthFlow = ({
const setStorageItem = (key: string, value: string) => {
if (typeof window === "undefined") return;
try {
// Use sessionStorage only — the flow state may contain client credentials;
// writing them to localStorage would persist across browser sessions and
// make them readable by any injected script (XSS).
// codeql[js/clear-text-storage-of-sensitive-data]
window.sessionStorage.setItem(key, value);
} catch (err) {
console.warn(`Failed to set storage item ${key}`, err);
}
setSecureItem(key, value);
};
const getStorageItem = (key: string): string | null => {
if (typeof window === "undefined") return null;
try {
// Try sessionStorage first, fall back to localStorage
return window.sessionStorage.getItem(key) || window.localStorage.getItem(key);
return getSecureItem(key);
} catch (err) {
console.warn(`Failed to get storage item ${key}`, err);
return null;

View file

@ -23,6 +23,7 @@ import {
} from "@/components/networking";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { extractErrorMessage } from "@/utils/errorUtils";
import { getSecureItem, setSecureItem } from "@/utils/secureStorage";
export type UserMcpOAuthStatus = "idle" | "authorizing" | "exchanging" | "success" | "error";
@ -79,22 +80,11 @@ const genChallenge = async (verifier: string) => {
};
const setStorage = (key: string, value: string) => {
try {
// Use sessionStorage only — do not write to localStorage.
// The flow state may contain the LiteLLM access token; writing it to
// localStorage would persist it across browser sessions and make it
// readable by any injected script (XSS).
// codeql[js/clear-text-storage-of-sensitive-data]
window.sessionStorage.setItem(key, value);
} catch (_) {}
setSecureItem(key, value);
};
const getStorage = (key: string): string | null => {
try {
return window.sessionStorage.getItem(key);
} catch (_) {
return null;
}
return getSecureItem(key);
};
const clearStorage = (...keys: string[]) => {

View file

@ -0,0 +1,34 @@
function encode(value: string): string {
// btoa cannot handle characters outside Latin-1, so we percent-encode first.
return btoa(
encodeURIComponent(value).replace(
/%([0-9A-F]{2})/g,
(_, p1) => String.fromCharCode(parseInt(p1, 16))
)
);
}
function decode(encoded: string): string {
return decodeURIComponent(
atob(encoded)
.split("")
.map((c) => "%" + c.charCodeAt(0).toString(16).padStart(2, "0"))
.join("")
);
}
export function setSecureItem(key: string, value: string): void {
window.sessionStorage.setItem(key, encode(value));
}
export function getSecureItem(key: string): string | null {
try {
const raw = window.sessionStorage.getItem(key);
if (raw === null) return null;
return decode(raw);
} catch {
// Corrupted or non-encoded legacy value — return null without deleting
// so that in-flight flows (e.g. OAuth) can time out naturally.
return null;
}
}

View file

@ -7,7 +7,7 @@ export default defineConfig({
setupFiles: ["tests/setupTests.ts"],
globals: true,
css: true, // lets you import CSS/modules without extra mocks
testTimeout: 10000,
testTimeout: 30000,
coverage: {
provider: "v8",
reporter: ["text", "lcov"],