diff --git a/.github/workflows/test-litellm-ui-unit.yml b/.github/workflows/test-litellm-ui-unit.yml new file mode 100644 index 00000000000..f0f9f504752 --- /dev/null +++ b/.github/workflows/test-litellm-ui-unit.yml @@ -0,0 +1,57 @@ +name: UI Unit Tests +permissions: + contents: read + +on: + pull_request: + branches: + - main + - litellm_internal_staging + - litellm_oss_staging + - "litellm_**" + push: + branches: + - litellm_internal_staging + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + ui-unit-tests: + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: ui/litellm-dashboard + + steps: + - name: Checkout repository + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: ui/litellm-dashboard/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Run UI unit tests (Vitest) + env: + CI: "true" + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + if [ -n "$BASE_SHA" ]; then + echo "Pull request: running only tests related to changes since $BASE_SHA" + npm run test -- --run --changed "$BASE_SHA" --passWithNoTests \ + --pool forks --poolOptions.forks.maxForks=4 + else + echo "Push to $GITHUB_REF_NAME: running the full suite" + npm run test -- --run --pool forks --poolOptions.forks.maxForks=4 + fi diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 26053822644..7a7de639d33 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -4066,6 +4066,7 @@ class JWTAuthBuilderResult(TypedDict): token: str team_id: Optional[str] user_id: Optional[str] + user_email: str | None end_user_id: Optional[str] org_id: Optional[str] team_membership: Optional[LiteLLM_TeamMembership] diff --git a/litellm/proxy/auth/handle_jwt.py b/litellm/proxy/auth/handle_jwt.py index a44318c072c..ff87d0e70da 100644 --- a/litellm/proxy/auth/handle_jwt.py +++ b/litellm/proxy/auth/handle_jwt.py @@ -1155,6 +1155,7 @@ class JWTAuthManager: org_id: Optional[str], api_key: str, jwt_valid_token: Optional[dict] = None, + user_email: str | None = None, ) -> Optional[JWTAuthBuilderResult]: """Check admin status and route access permissions""" if not jwt_handler.is_admin(scopes=scopes): @@ -1179,6 +1180,7 @@ class JWTAuthManager: token=api_key, team_id=None, user_id=user_id, + user_email=user_email, end_user_id=None, org_id=org_id, team_membership=None, @@ -2068,7 +2070,7 @@ class JWTAuthManager: # Check admin access admin_result = await JWTAuthManager.check_admin_access( - jwt_handler, scopes, route, user_id, org_id, api_key, jwt_valid_token + jwt_handler, scopes, route, user_id, org_id, api_key, jwt_valid_token, user_email=user_email ) if admin_result: await JWTAuthManager._attach_team_from_header_for_admin( @@ -2303,6 +2305,7 @@ class JWTAuthManager: team_id=team_id, team_object=team_object, user_id=user_id, + user_email=(user_object.user_email if user_object is not None and user_object.user_email else user_email), user_object=user_object, org_id=resolved_org_id, # Use resolved org_id (from alias lookup if applicable) org_object=org_object, diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index 4846f6e00c0..1af1c05a47a 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -1295,6 +1295,7 @@ async def _user_api_key_auth_builder( team_id = result["team_id"] team_object = result["team_object"] user_id = result["user_id"] + user_email = result["user_email"] user_object = result["user_object"] end_user_id = result["end_user_id"] org_id = result["org_id"] @@ -1319,6 +1320,7 @@ async def _user_api_key_auth_builder( api_key=None, user_role=LitellmUserRoles.PROXY_ADMIN, user_id=user_id, + user_email=user_email, team_id=team_id, team_alias=(team_object.team_alias if team_object is not None else None), team_tpm_limit=(team_object.tpm_limit if team_object is not None else None), @@ -1344,6 +1346,7 @@ async def _user_api_key_auth_builder( else LitellmUserRoles.INTERNAL_USER ), user_id=user_id, + user_email=user_email, org_id=org_id, parent_otel_span=parent_otel_span, end_user_id=end_user_id, @@ -1385,6 +1388,7 @@ async def _user_api_key_auth_builder( ) if auto_registered is not None: auto_registered.jwt_claims = jwt_claims + auto_registered.user_email = user_email valid_token = auto_registered api_key = valid_token.token or "" diff --git a/tests/e2e/e2e_http.py b/tests/e2e/e2e_http.py index 1c6048a3688..7ec4a439e10 100644 --- a/tests/e2e/e2e_http.py +++ b/tests/e2e/e2e_http.py @@ -62,6 +62,7 @@ R = TypeVar("R", bound=BaseModel) class Success(BaseModel, Generic[R]): kind: Literal["success"] = "success" + status_code: int data: R @@ -159,6 +160,18 @@ def unwrap[R: BaseModel](result: Result[R]) -> R: raise AssertionError(result) +def unwrap_status[R: BaseModel](result: Result[R], expected_status: int) -> R: + """Like unwrap, but also pins the exact HTTP status the success came back on, + for routes whose contract is a specific 2xx (e.g. 201 Created on a submission).""" + match result: + case Success(status_code=status_code, data=data) if status_code == expected_status: + return data + case Success(status_code=status_code): + raise AssertionError(f"expected HTTP {expected_status}, got {status_code}") + case _: + raise AssertionError(result) + + def is_ok[R: BaseModel](result: Result[R]) -> bool: match result: case Success(): @@ -199,7 +212,7 @@ def _classify[R: BaseModel]( if not resp.ok: return UnknownApiError(status_code=resp.status_code, body=resp.text) try: - return Success(data=response_type.model_validate(resp.json())) + return Success(status_code=resp.status_code, data=response_type.model_validate(resp.json())) except Exception as exc: # noqa: BLE001 - any parse/validation failure is a value return ValidationError(message=str(exc)) @@ -286,6 +299,26 @@ def patch[R: BaseModel]( return _classify(resp, response_type) +def put[R: BaseModel]( + url: URL, + *, + headers: BaseModel, + json: BaseModel, + response_type: type[R], + timeout: float = 30.0, +) -> Result[R]: + try: + resp = requests.put( + str(url), + headers=_headers(headers), + json=json.model_dump(by_alias=True, exclude_none=True), + timeout=timeout, + ) + except requests.RequestException as exc: + return NetworkError(message=str(exc)) + return _classify(resp, response_type) + + def probe( url: URL, *, headers: BaseModel, params: BaseModel, timeout: float = 30.0 ) -> ProbeResult: diff --git a/tests/e2e/llm_translation/test_messages_azure_foundry_e2e.py b/tests/e2e/llm_translation/test_messages_azure_foundry_e2e.py new file mode 100644 index 00000000000..3f2907f202e --- /dev/null +++ b/tests/e2e/llm_translation/test_messages_azure_foundry_e2e.py @@ -0,0 +1,155 @@ +"""Live e2e: POST /v1/messages routed to Azure AI Foundry Anthropic deployments. + +Registers `azure_ai/` deployments at runtime and drives the Messages +endpoint through the gateway across the behaviors an Anthropic client relies on: +a basic completion, a streamed completion, and tool use (non-streaming and +streaming). Auth is the Azure API key (`x-api-key`); the deployment reads +`AZURE_AI_API_BASE` / `AZURE_AI_API_KEY` from the proxy env, so no secret is +sent in the request. +""" + +from __future__ import annotations + +import pytest + +from e2e_config import unique_marker +from e2e_http import StreamingResponse, require_successful_call, unwrap +from endpoints_client import EndpointsClient +from lifecycle import ResourceManager +from models import ( + AnthropicCustomTool, + AnthropicMessagesBody, + ChatMessage, + JsonSchemaProperty, + LiteLLMParamsBody, + ToolInputSchema, +) + +pytestmark = pytest.mark.e2e + +AZURE_FOUNDRY_MODEL = "azure_ai/claude-haiku-4-5" + +WEATHER_TOOL = AnthropicCustomTool( + name="get_weather", + description="Get the current weather for a city.", + input_schema=ToolInputSchema( + properties={"city": JsonSchemaProperty(type="string")}, + required=["city"], + ), +) + + +def _assert_streamed_ok(result: StreamingResponse) -> None: + require_successful_call(result) + assert result.is_streaming, f"response was not streamed: {result.headers}" + assert not result.stream_error, f"stream errored: {result.stream_error}" + assert result.stream_events, "stream produced no SSE events" + assert any("content_block_delta" in event for event in result.stream_events), ( + "stream carried no content deltas" + ) + assert any("message_stop" in event for event in result.stream_events), ( + "stream never reached message_stop" + ) + + +class TestAzureFoundryMessages: + def _register( + self, endpoints_client: EndpointsClient, resources: ResourceManager + ) -> tuple[str, str]: + model = f"e2e-azure-foundry-messages-{unique_marker()}" + model_id = endpoints_client.create_model( + model, + LiteLLMParamsBody( + model=AZURE_FOUNDRY_MODEL, + api_base="os.environ/AZURE_AI_API_BASE", + api_key="os.environ/AZURE_AI_API_KEY", + ), + ) + resources.defer(lambda: endpoints_client.delete_model(model_id)) + return model, resources.key(models=[model]) + + @pytest.mark.covers("llm.messages.azure_foundry.basic.nonstream.works") + def test_basic_nonstream( + self, endpoints_client: EndpointsClient, resources: ResourceManager + ) -> None: + model, key = self._register(endpoints_client, resources) + response = unwrap( + endpoints_client.proxy.messages( + key, + AnthropicMessagesBody( + model=model, + max_tokens=64, + messages=[ChatMessage(role="user", content="Reply with one word.")], + ), + ) + ) + assert response.content, f"no content blocks in response: {response}" + text = "".join(block.text or "" for block in response.content if block.type == "text") + assert text.strip(), f"/v1/messages returned no text: {response}" + + @pytest.mark.covers("llm.messages.azure_foundry.basic.stream.works") + def test_basic_stream( + self, endpoints_client: EndpointsClient, resources: ResourceManager + ) -> None: + model, key = self._register(endpoints_client, resources) + result = endpoints_client.proxy.messages_stream( + key, + AnthropicMessagesBody( + model=model, + max_tokens=64, + stream=True, + messages=[ChatMessage(role="user", content="Count from one to three.")], + ), + ) + _assert_streamed_ok(result) + + @pytest.mark.covers("llm.messages.azure_foundry.tool_use.nonstream.works") + def test_tool_use_nonstream( + self, endpoints_client: EndpointsClient, resources: ResourceManager + ) -> None: + model, key = self._register(endpoints_client, resources) + response = unwrap( + endpoints_client.proxy.messages( + key, + AnthropicMessagesBody( + model=model, + max_tokens=256, + tools=[WEATHER_TOOL], + messages=[ + ChatMessage(role="user", content="What is the weather in Paris? Use the tool.") + ], + ), + ) + ) + assert response.content, f"no content blocks in response: {response}" + assert any(block.type == "tool_use" for block in response.content), ( + f"model did not call the tool: {response}" + ) + + @pytest.mark.covers("llm.messages.azure_foundry.tool_use.stream.works") + def test_tool_use_stream( + self, endpoints_client: EndpointsClient, resources: ResourceManager + ) -> None: + model, key = self._register(endpoints_client, resources) + result = endpoints_client.proxy.messages_stream( + key, + AnthropicMessagesBody( + model=model, + max_tokens=256, + stream=True, + tools=[WEATHER_TOOL], + messages=[ + ChatMessage(role="user", content="What is the weather in Paris? Use the tool.") + ], + ), + ) + require_successful_call(result) + assert result.is_streaming, f"response was not streamed: {result.headers}" + assert not result.stream_error, f"stream errored: {result.stream_error}" + assert result.stream_events, "stream produced no SSE events" + assert any("tool_use" in event for event in result.stream_events), ( + "stream carried no tool_use block" + ) + assert any("message_stop" in event for event in result.stream_events), ( + "stream never reached message_stop" + ) diff --git a/tests/e2e/llm_translation/test_messages_e2e.py b/tests/e2e/llm_translation/test_messages_e2e.py index b0a48f22118..a14bf8e82b3 100644 --- a/tests/e2e/llm_translation/test_messages_e2e.py +++ b/tests/e2e/llm_translation/test_messages_e2e.py @@ -1,7 +1,8 @@ """Live e2e: POST /v1/messages (Anthropic Messages API) returns a real completion. Registers an Anthropic deployment at runtime, drives the Messages endpoint through -the gateway, and asserts an assistant message with text came back. Migrated from +the gateway, and asserts an assistant message with text came back, both +non-streaming and streamed. Migrated from litellm-regression-tests/tests/test_inference_endpoints.py. """ @@ -10,18 +11,34 @@ from __future__ import annotations import pytest from e2e_config import unique_marker -from e2e_http import require_successful_call +from e2e_http import require_successful_call, unwrap from endpoints_client import EndpointsClient, MessagesResult from lifecycle import ResourceManager -from models import LiteLLMParamsBody +from models import ( + AnthropicCustomTool, + AnthropicMessagesBody, + ChatMessage, + JsonSchemaProperty, + LiteLLMParamsBody, + ToolInputSchema, +) pytestmark = pytest.mark.e2e +WEATHER_TOOL = AnthropicCustomTool( + name="get_weather", + description="Get the current weather for a city.", + input_schema=ToolInputSchema( + properties={"city": JsonSchemaProperty(type="string")}, + required=["city"], + ), +) + class TestAnthropicMessages: - def test_messages_returns_completion( + def _register( self, endpoints_client: EndpointsClient, resources: ResourceManager - ) -> None: + ) -> tuple[str, str]: model = f"e2e-messages-{unique_marker()}" model_id = endpoints_client.create_model( model, @@ -30,10 +47,66 @@ class TestAnthropicMessages: ), ) resources.defer(lambda: endpoints_client.delete_model(model_id)) - key = resources.key() + return model, resources.key() + + @pytest.mark.covers("llm.messages.anthropic.basic.nonstream.works") + def test_messages_returns_completion( + self, endpoints_client: EndpointsClient, resources: ResourceManager + ) -> None: + model, key = self._register(endpoints_client, resources) result = endpoints_client.messages(key, model, "reply with one word") require_successful_call(result) parsed = MessagesResult.model_validate_json(result.body) assert parsed.role == "assistant", f"unexpected role: {result.body[:300]}" assert parsed.text.strip(), f"/v1/messages returned no text: {result.body[:300]}" + + @pytest.mark.covers("llm.messages.anthropic.basic.stream.works") + def test_messages_streams_completion( + self, endpoints_client: EndpointsClient, resources: ResourceManager + ) -> None: + model, key = self._register(endpoints_client, resources) + + result = endpoints_client.proxy.messages_stream( + key, + AnthropicMessagesBody( + model=model, + max_tokens=64, + stream=True, + messages=[ChatMessage(role="user", content="Count from one to three.")], + ), + ) + require_successful_call(result) + assert result.is_streaming, f"response was not streamed: {result.headers}" + assert not result.stream_error, f"stream errored: {result.stream_error}" + assert result.stream_events, "stream produced no SSE events" + assert any("content_block_delta" in event for event in result.stream_events), ( + "stream carried no content deltas" + ) + assert any("message_stop" in event for event in result.stream_events), ( + "stream never reached message_stop" + ) + + @pytest.mark.covers("llm.messages.anthropic.tool_use.nonstream.works") + def test_messages_tool_use( + self, endpoints_client: EndpointsClient, resources: ResourceManager + ) -> None: + model, key = self._register(endpoints_client, resources) + + response = unwrap( + endpoints_client.proxy.messages( + key, + AnthropicMessagesBody( + model=model, + max_tokens=256, + tools=[WEATHER_TOOL], + messages=[ + ChatMessage(role="user", content="What is the weather in Paris? Use the tool.") + ], + ), + ) + ) + assert response.content, f"no content blocks in response: {response}" + assert any(block.type == "tool_use" for block in response.content), ( + f"model did not call the tool: {response}" + ) diff --git a/tests/e2e/management/test_config_misc_endpoints_e2e.py b/tests/e2e/management/test_config_misc_endpoints_e2e.py new file mode 100644 index 00000000000..6c4de621271 --- /dev/null +++ b/tests/e2e/management/test_config_misc_endpoints_e2e.py @@ -0,0 +1,698 @@ +"""Live e2e: the config and miscellaneous Management/UI routes. + +One method per registry cell, each asserting the real contract against a live +proxy: read-only inventory routes return their documented shape, stateless +validators compute their verdict from the request, and the write routes persist +so a read-back reflects the change. The two routes that mutate global proxy state +(cache settings and router settings, both driven from the admin UI) are exercised +with a benign, self-restoring change so a shared proxy is left as it was found. +""" + +from __future__ import annotations + +import math +import time +from collections.abc import Callable + +import pytest +from pydantic import BaseModel + +from e2e_config import unique_marker +from e2e_http import NoBody, Success, unwrap, unwrap_status +from lifecycle import ResourceManager +from management_client import ManagementClient +from models import KeyGenerateBody, LiteLLMParamsBody, TeamNewBody + +pytestmark = pytest.mark.e2e + + +def _poll[T](client: ManagementClient, attempt: Callable[[], T | None], failure: str) -> T: + deadline = time.monotonic() + client.proxy.poll_timeout + while time.monotonic() < deadline: + found = attempt() + if found is not None: + return found + time.sleep(client.proxy.poll_interval) + pytest.fail(failure) + + +# ---- callbacks ------------------------------------------------------------- + + +class CallbacksListResponse(BaseModel): + success: list[str] + failure: list[str] + success_and_failure: list[str] + + +# ---- cost estimate --------------------------------------------------------- + + +class CostEstimateBody(BaseModel): + model: str + input_tokens: int + output_tokens: int + num_requests_per_day: int | None = None + + +class CostEstimateResponse(BaseModel): + model: str + input_tokens: int + output_tokens: int + cost_per_request: float + input_cost_per_request: float + output_cost_per_request: float + margin_cost_per_request: float + daily_cost: float | None = None + provider: str | None = None + + +# ---- credential migration check -------------------------------------------- + + +class MigrationReport(BaseModel): + residual_legacy: int + total_undecryptable: int + + +class MigrationCheckResponse(BaseModel): + status: str + report: MigrationReport + + +# ---- tool + workflow inventories ------------------------------------------- + + +class ToolListEntry(BaseModel): + name: str | None = None + + +class ToolListResponse(BaseModel): + tools: list[ToolListEntry] + total: int + + +class WorkflowRunEntry(BaseModel): + workflow_id: str | None = None + + +class WorkflowRunsResponse(BaseModel): + runs: list[WorkflowRunEntry] + count: int + + +# ---- compliance ------------------------------------------------------------ + + +class ComplianceGdprBody(BaseModel): + request_id: str + user_id: str + model: str + timestamp: str + + +class ComplianceCheck(BaseModel): + check_name: str + article: str + passed: bool + detail: str + + +class ComplianceResponse(BaseModel): + compliant: bool + regulation: str + checks: list[ComplianceCheck] + + +# ---- cache settings -------------------------------------------------------- + + +class CacheSettingsValue(BaseModel): + type: str + host: str = "" + port: str = "" + + +class CacheSettingsUpdateBody(BaseModel): + cache_settings: CacheSettingsValue + + +class CacheCurrentValues(BaseModel): + type: str | None = None + host: str | None = None + port: str | None = None + + +class CacheGetResponse(BaseModel): + current_values: CacheCurrentValues + + +class CacheUpdateResponse(BaseModel): + status: str + settings: CacheSettingsValue + + +# ---- fallback management --------------------------------------------------- + + +class FallbackShape(BaseModel): + model: str + fallback_models: list[str] + fallback_type: str + + +class FallbackCreateBody(FallbackShape): + pass + + +class FallbackResponse(FallbackShape): + message: str + + +class FallbackGetParams(BaseModel): + fallback_type: str + + +class FallbackGetResponse(FallbackShape): + pass + + +# ---- jwt key mapping ------------------------------------------------------- + + +class JwtKeyMappingNewBody(BaseModel): + jwt_claim_name: str + jwt_claim_value: str + key: str + description: str + + +class JwtInfoParams(BaseModel): + id: str + + +class JwtDeleteBody(BaseModel): + id: str + + +class JwtKeyMappingResponse(BaseModel): + id: str + jwt_claim_name: str + jwt_claim_value: str + is_active: bool + description: str | None = None + + +# ---- router settings via /config/update ------------------------------------ + + +class RouterSettingsPatch(BaseModel): + num_retries: int + + +class ConfigUpdateBody(BaseModel): + router_settings: RouterSettingsPatch + + +class ConfigUpdateResponse(BaseModel): + message: str + + +class RouterCurrentValues(BaseModel): + num_retries: int | None = None + + +class RouterSettingsResponse(BaseModel): + current_values: RouterCurrentValues + + +# ---- mcp server submission ------------------------------------------------- + + +class McpRegisterBody(BaseModel): + server_name: str + url: str + transport: str + description: str + + +class McpServerResponse(BaseModel): + server_id: str + server_name: str | None = None + approval_status: str + transport: str + url: str | None = None + + +class TestInventoryRoutes: + @pytest.mark.covers("mgmt.callback.list.happy_path") + def test_callbacks_list_reports_active_logging_callbacks(self, client: ManagementClient) -> None: + listing = unwrap( + client.proxy.transport.get( + "/callbacks/list", + headers=client.proxy.transport.master, + params=NoBody(), + response_type=CallbacksListResponse, + ) + ) + every = [*listing.success, *listing.failure, *listing.success_and_failure] + assert every, "/callbacks/list reported no active logging callbacks; the proxy always runs the db logger" + assert "_ProxyDBLogger" in every, ( + f"/callbacks/list omitted the always-on _ProxyDBLogger spend logger; got {every}" + ) + + @pytest.mark.covers("mgmt.tool_management.list.happy_path") + def test_tool_list_returns_catalog_with_consistent_total(self, client: ManagementClient) -> None: + listing = unwrap( + client.proxy.transport.get( + "/v1/tool/list", + headers=client.proxy.transport.master, + params=NoBody(), + response_type=ToolListResponse, + ) + ) + assert listing.total == len(listing.tools), ( + f"/v1/tool/list total {listing.total} disagrees with the {len(listing.tools)} tools returned" + ) + + @pytest.mark.covers("mgmt.workflow.list.happy_path") + def test_workflow_runs_list_returns_consistent_count(self, client: ManagementClient) -> None: + listing = unwrap( + client.proxy.transport.get( + "/v1/workflows/runs", + headers=client.proxy.transport.master, + params=NoBody(), + response_type=WorkflowRunsResponse, + ) + ) + assert listing.count == len(listing.runs), ( + f"/v1/workflows/runs count {listing.count} disagrees with the {len(listing.runs)} runs returned" + ) + + @pytest.mark.covers("mgmt.credential_migration.check.happy_path") + def test_credential_migration_check_reports_residual_scan(self, client: ManagementClient) -> None: + report = unwrap( + client.proxy.transport.get( + "/credentials/migrate-encryption/check", + headers=client.proxy.transport.master, + params=NoBody(), + response_type=MigrationCheckResponse, + ) + ) + assert report.status == "success", f"migrate-encryption/check status {report.status!r}, expected 'success'" + assert report.report.residual_legacy >= 0, ( + f"residual_legacy count is negative ({report.report.residual_legacy}); the scan is broken" + ) + assert report.report.total_undecryptable >= 0, ( + f"total_undecryptable count is negative ({report.report.total_undecryptable}); the scan is broken" + ) + + +class TestCostEstimate: + @pytest.mark.covers("mgmt.cost_tracking.estimate.happy_path") + def test_estimate_computes_cost_from_token_counts(self, client: ManagementClient) -> None: + estimate = unwrap( + client.proxy.transport.post( + "/cost/estimate", + headers=client.proxy.transport.master, + json=CostEstimateBody( + model="gpt-4o-mini", input_tokens=1000, output_tokens=500, num_requests_per_day=100 + ), + response_type=CostEstimateResponse, + ) + ) + assert estimate.input_cost_per_request > 0, ( + f"input cost per request is {estimate.input_cost_per_request}; a priced model must cost more than zero" + ) + assert estimate.output_cost_per_request > 0, ( + f"output cost per request is {estimate.output_cost_per_request}; a priced model must cost more than zero" + ) + expected_per_request = ( + estimate.input_cost_per_request + estimate.output_cost_per_request + estimate.margin_cost_per_request + ) + assert math.isclose(estimate.cost_per_request, expected_per_request, rel_tol=1e-9), ( + f"cost_per_request {estimate.cost_per_request} != input+output+margin {expected_per_request}" + ) + assert estimate.daily_cost is not None and math.isclose( + estimate.daily_cost, estimate.cost_per_request * 100, rel_tol=1e-9 + ), f"daily_cost {estimate.daily_cost} != cost_per_request * 100 requests {estimate.cost_per_request * 100}" + + +class TestComplianceRoutes: + @pytest.mark.covers("mgmt.compliance.gdpr.happy_path") + def test_gdpr_check_derives_verdict_from_the_request(self, client: ManagementClient) -> None: + result = unwrap( + client.proxy.transport.post( + "/compliance/gdpr", + headers=client.proxy.transport.master, + json=ComplianceGdprBody( + request_id=f"e2e-gdpr-{unique_marker()}", + user_id=f"e2e-user-{unique_marker()}", + model="gpt-4o-mini", + timestamp="2026-07-21T00:00:00Z", + ), + response_type=ComplianceResponse, + ) + ) + assert result.regulation == "GDPR", ( + f"/compliance/gdpr reported regulation {result.regulation!r}, expected 'GDPR'" + ) + articles = {check.article for check in result.checks} + assert articles == {"Art. 32", "Art. 5(1)(c)", "Art. 30"}, ( + f"/compliance/gdpr returned articles {articles}, expected the three GDPR articles" + ) + assert result.compliant == all(check.passed for check in result.checks), ( + "the overall compliant verdict must be the conjunction of the individual checks" + ) + assert all(check.check_name and check.detail for check in result.checks), ( + "every compliance check must carry a name and a human-readable detail" + ) + + +class TestCacheSettings: + @pytest.mark.covers("mgmt.cache_settings.update.happy_path") + def test_update_persists_cache_backend_to_get( + self, client: ManagementClient, resources: ResourceManager + ) -> None: + """Exercise the update route without changing global state: capture the live + cache backend and write exactly that back, so the config the proxy ends on is + byte-for-byte the one it started with. A teardown restore of the same captured + settings is the safety net if the body fails partway. The update route is only + meaningful against a configured cache, so an unconfigured proxy fails loudly + here rather than being silently switched to redis.""" + before = self._read_settings(client) + assert before.type is not None, ( + "GET /cache/settings reported no cache type; refusing to invent one and mutate the shared proxy" + ) + captured = CacheSettingsValue(type=before.type, host=before.host or "", port=before.port or "") + resources.defer(lambda: self._write_settings(client, captured)) + + updated = unwrap( + client.proxy.transport.post( + "/cache/settings", + headers=client.proxy.transport.master, + json=CacheSettingsUpdateBody(cache_settings=captured), + response_type=CacheUpdateResponse, + ) + ) + assert updated.status == "success", f"/cache/settings update status {updated.status!r}, expected 'success'" + assert updated.settings.type == captured.type, ( + f"/cache/settings echoed type {updated.settings.type!r}, wrote {captured.type!r}" + ) + + def reflected() -> CacheCurrentValues | None: + current = self._read_settings(client) + return current if current.type == captured.type else None + + after = _poll(client, reflected, f"/cache/settings never reported type {captured.type!r} after the update") + assert after.host == captured.host and after.port == captured.port, ( + f"/cache/settings persisted host/port {after.host!r}/{after.port!r}, " + f"wrote {captured.host!r}/{captured.port!r}" + ) + + @staticmethod + def _read_settings(client: ManagementClient) -> CacheCurrentValues: + return unwrap( + client.proxy.transport.get( + "/cache/settings", + headers=client.proxy.transport.master, + params=NoBody(), + response_type=CacheGetResponse, + ) + ).current_values + + @staticmethod + def _write_settings(client: ManagementClient, settings: CacheSettingsValue) -> None: + _ = unwrap( + client.proxy.transport.post( + "/cache/settings", + headers=client.proxy.transport.master, + json=CacheSettingsUpdateBody(cache_settings=settings), + response_type=CacheUpdateResponse, + ) + ) + + +class TestFallbackManagement: + @pytest.mark.covers("mgmt.fallback_management.update.happy_path") + def test_create_persists_and_is_read_back(self, client: ManagementClient, resources: ResourceManager) -> None: + primary = f"e2e-fallback-primary-{unique_marker()}" + secondary = f"e2e-fallback-secondary-{unique_marker()}" + params = LiteLLMParamsBody(model="openai/gpt-5.5", api_key="e2e-dummy-key") + primary_id = client.proxy.create_model(primary, params) + resources.defer(lambda: client.proxy.delete_model(primary_id)) + secondary_id = client.proxy.create_model(secondary, params) + resources.defer(lambda: client.proxy.delete_model(secondary_id)) + resources.defer(lambda: self._delete_fallback(client, primary)) + + created = unwrap( + client.proxy.transport.post( + "/fallback", + headers=client.proxy.transport.master, + json=FallbackCreateBody(model=primary, fallback_models=[secondary], fallback_type="general"), + response_type=FallbackResponse, + ) + ) + assert created.model == primary and created.fallback_models == [secondary], ( + f"/fallback echoed model={created.model!r} fallbacks={created.fallback_models}, " + f"configured {primary!r} -> [{secondary!r}]" + ) + + def read_back() -> FallbackGetResponse | None: + result = client.proxy.transport.get( + f"/fallback/{primary}", + headers=client.proxy.transport.master, + params=FallbackGetParams(fallback_type="general"), + response_type=FallbackGetResponse, + ) + match result: + case Success(data=data) if secondary in data.fallback_models: + return data + case _: + return None + + got = _poll(client, read_back, f"GET /fallback/{primary} never reported {secondary} after /fallback") + assert got.fallback_models == [secondary], ( + f"GET /fallback/{primary} reports fallbacks {got.fallback_models}, configured [{secondary!r}]" + ) + + @staticmethod + def _delete_fallback(client: ManagementClient, model: str) -> None: + _ = client.proxy.transport.delete( + f"/fallback/{model}", + headers=client.proxy.transport.master, + json=NoBody(), + params=FallbackGetParams(fallback_type="general"), + response_type=NoBody, + ) + + +class TestJwtKeyMapping: + @pytest.mark.covers("mgmt.jwt_key_mapping.new.happy_path") + def test_new_persists_mapping_and_is_read_back( + self, client: ManagementClient, resources: ResourceManager + ) -> None: + key = client.proxy.generate_key(KeyGenerateBody()) + resources.defer(lambda: client.proxy.delete_key(key)) + claim_value = f"e2e_jwt_{unique_marker()}" + + created = unwrap( + client.proxy.transport.post( + "/jwt/key/mapping/new", + headers=client.proxy.transport.master, + json=JwtKeyMappingNewBody( + jwt_claim_name="team_id", + jwt_claim_value=claim_value, + key=key, + description="e2e coverage mapping", + ), + response_type=JwtKeyMappingResponse, + ) + ) + resources.defer(lambda: self._delete_mapping(client, created.id)) + assert created.jwt_claim_value == claim_value and created.is_active, ( + f"/jwt/key/mapping/new returned claim_value={created.jwt_claim_value!r} active={created.is_active}, " + f"configured {claim_value!r} active=True" + ) + + info = unwrap( + client.proxy.transport.get( + "/jwt/key/mapping/info", + headers=client.proxy.transport.master, + params=JwtInfoParams(id=created.id), + response_type=JwtKeyMappingResponse, + ) + ) + assert info.id == created.id and info.jwt_claim_name == "team_id" and info.jwt_claim_value == claim_value, ( + f"/jwt/key/mapping/info reports {info.jwt_claim_name!r}={info.jwt_claim_value!r} for id {info.id}, " + f"created team_id={claim_value!r}" + ) + + @staticmethod + def _delete_mapping(client: ManagementClient, mapping_id: str) -> None: + _ = client.proxy.transport.post( + "/jwt/key/mapping/delete", + headers=client.proxy.transport.master, + json=JwtDeleteBody(id=mapping_id), + response_type=NoBody, + ) + + +class TestRouterSettings: + @pytest.mark.covers("mgmt.router_settings.update.happy_path") + def test_config_update_persists_router_setting_to_get( + self, client: ManagementClient, resources: ResourceManager + ) -> None: + """/config/update is the only write path for router_settings (there is no + dedicated router-settings write route). The change is restored on teardown so + the shared proxy keeps its original retry policy.""" + original = self._read_num_retries(client) + assert original is not None, "GET /router/settings did not report num_retries; cannot prove a change" + resources.defer(lambda: self._write_num_retries(client, original)) + + target = original + 5 + response = unwrap( + client.proxy.transport.post( + "/config/update", + headers=client.proxy.transport.master, + json=ConfigUpdateBody(router_settings=RouterSettingsPatch(num_retries=target)), + response_type=ConfigUpdateResponse, + ) + ) + assert "success" in response.message.lower(), ( + f"/config/update reported {response.message!r}, expected a success message" + ) + + _ = _poll( + client, + lambda: True if self._read_num_retries(client) == target else None, + f"GET /router/settings never reported num_retries {target} after /config/update", + ) + + self._write_num_retries(client, original) + restored = _poll( + client, + lambda: original if self._read_num_retries(client) == original else None, + f"GET /router/settings never returned to the original num_retries {original} after the restore", + ) + assert restored == original, f"router num_retries left at {restored}, expected the original {original}" + + @staticmethod + def _read_num_retries(client: ManagementClient) -> int | None: + return unwrap( + client.proxy.transport.get( + "/router/settings", + headers=client.proxy.transport.master, + params=NoBody(), + response_type=RouterSettingsResponse, + ) + ).current_values.num_retries + + @staticmethod + def _write_num_retries(client: ManagementClient, value: int) -> None: + _ = unwrap( + client.proxy.transport.post( + "/config/update", + headers=client.proxy.transport.master, + json=ConfigUpdateBody(router_settings=RouterSettingsPatch(num_retries=value)), + response_type=ConfigUpdateResponse, + ) + ) + + +class TestMcpServerSubmission: + @pytest.mark.covers("mgmt.mcp_server.register.happy_path") + def test_register_submits_pending_server(self, client: ManagementClient, resources: ResourceManager) -> None: + """A non-admin, team-scoped key submits an MCP server for review; the proxy + stores it as pending_review without loading it into the runtime registry.""" + team_id = client.create_team(TeamNewBody(team_alias=f"e2e-mcp-team-{unique_marker()}")) + resources.defer(lambda: client.delete_team(team_id)) + team_key = client.proxy.generate_key(KeyGenerateBody(team_id=team_id)) + resources.defer(lambda: client.proxy.delete_key(team_key)) + + server_name = f"e2e_mcp_{unique_marker()}" + submitted = unwrap_status( + client.proxy.transport.post( + "/v1/mcp/server/register", + headers=client.proxy.transport.bearer(team_key), + json=McpRegisterBody( + server_name=server_name, + url="https://example.com/mcp", + transport="sse", + description="e2e coverage submission", + ), + response_type=McpServerResponse, + ), + 201, + ) + resources.defer(lambda: self._delete_server(client, submitted.server_id)) + assert submitted.approval_status == "pending_review", ( + f"a user submission must be pending_review, got {submitted.approval_status!r}" + ) + assert submitted.server_name == server_name and submitted.transport == "sse", ( + f"/v1/mcp/server/register echoed name={submitted.server_name!r} transport={submitted.transport!r}, " + f"configured {server_name!r}/sse" + ) + + @pytest.mark.covers("mgmt.mcp_server.approve.persists") + def test_approve_activates_submission_and_persists( + self, client: ManagementClient, resources: ResourceManager + ) -> None: + """An admin approving a pending submission flips it to active, and the change + persists to a fresh read of the server.""" + team_id = client.create_team(TeamNewBody(team_alias=f"e2e-mcp-team-{unique_marker()}")) + resources.defer(lambda: client.delete_team(team_id)) + team_key = client.proxy.generate_key(KeyGenerateBody(team_id=team_id)) + resources.defer(lambda: client.proxy.delete_key(team_key)) + + submitted = unwrap( + client.proxy.transport.post( + "/v1/mcp/server/register", + headers=client.proxy.transport.bearer(team_key), + json=McpRegisterBody( + server_name=f"e2e_mcp_{unique_marker()}", + url="https://example.com/mcp", + transport="sse", + description="e2e coverage submission", + ), + response_type=McpServerResponse, + ) + ) + resources.defer(lambda: self._delete_server(client, submitted.server_id)) + assert submitted.approval_status == "pending_review", ( + f"a fresh submission must be pending_review before approval, got {submitted.approval_status!r}" + ) + + approved = unwrap( + client.proxy.transport.put( + f"/v1/mcp/server/{submitted.server_id}/approve", + headers=client.proxy.transport.master, + json=NoBody(), + response_type=McpServerResponse, + ) + ) + assert approved.approval_status == "active", ( + f"approve must flip the submission to active, got {approved.approval_status!r}" + ) + + fetched = unwrap( + client.proxy.transport.get( + f"/v1/mcp/server/{submitted.server_id}", + headers=client.proxy.transport.master, + params=NoBody(), + response_type=McpServerResponse, + ) + ) + assert fetched.server_id == submitted.server_id and fetched.approval_status == "active", ( + f"GET /v1/mcp/server/{submitted.server_id} reports approval_status {fetched.approval_status!r} " + "after approve, expected 'active'" + ) + + @staticmethod + def _delete_server(client: ManagementClient, server_id: str) -> None: + _ = client.proxy.transport.delete( + f"/v1/mcp/server/{server_id}", + headers=client.proxy.transport.master, + json=NoBody(), + response_type=NoBody, + ) diff --git a/tests/e2e/models.py b/tests/e2e/models.py index 37438010c3b..8b6c454fa1e 100644 --- a/tests/e2e/models.py +++ b/tests/e2e/models.py @@ -286,6 +286,7 @@ class CountTokensBody(BaseModel): class AnthropicContentBlock(BaseModel): type: str | None = None + text: str | None = None class AnthropicMessagesResponse(BaseModel): diff --git a/tests/e2e/transport.py b/tests/e2e/transport.py index ce33face1d2..f7061b03a46 100644 --- a/tests/e2e/transport.py +++ b/tests/e2e/transport.py @@ -65,6 +65,10 @@ class Transport(Protocol): self, path: str, *, headers: BaseModel, json: BaseModel, response_type: type[R] ) -> Result[R]: ... + def put[R: BaseModel]( + self, path: str, *, headers: BaseModel, json: BaseModel, response_type: type[R] + ) -> Result[R]: ... + def probe(self, path: str, *, params: BaseModel) -> ProbeResult: ... def upload[R: BaseModel]( @@ -159,6 +163,17 @@ class HttpTransport: timeout=self.request_timeout, ) + def put[R: BaseModel]( + self, path: str, *, headers: BaseModel, json: BaseModel, response_type: type[R] + ) -> Result[R]: + return e2e_http.put( + self._url(path), + headers=headers, + json=json, + response_type=response_type, + timeout=self.request_timeout, + ) + def stream( self, path: str, *, headers: BaseModel, json: BaseModel ) -> StreamingResponse: @@ -319,6 +334,13 @@ class SplitTransport: path, headers=headers, json=json, response_type=response_type ) + def put[R: BaseModel]( + self, path: str, *, headers: BaseModel, json: BaseModel, response_type: type[R] + ) -> Result[R]: + return self._route(path).put( + path, headers=headers, json=json, response_type=response_type + ) + def stream( self, path: str, *, headers: BaseModel, json: BaseModel ) -> StreamingResponse: diff --git a/tests/proxy_unit_tests/test_user_api_key_auth.py b/tests/proxy_unit_tests/test_user_api_key_auth.py index 5471d2668e4..59c4caefa33 100644 --- a/tests/proxy_unit_tests/test_user_api_key_auth.py +++ b/tests/proxy_unit_tests/test_user_api_key_auth.py @@ -1115,6 +1115,7 @@ async def test_jwt_non_admin_team_route_access(monkeypatch): "team_id": None, "team_object": None, "user_id": None, + "user_email": None, "user_object": None, "org_id": None, "org_object": None, diff --git a/tests/test_litellm/proxy/auth/test_handle_jwt.py b/tests/test_litellm/proxy/auth/test_handle_jwt.py index 13041950f98..ffc5241d027 100644 --- a/tests/test_litellm/proxy/auth/test_handle_jwt.py +++ b/tests/test_litellm/proxy/auth/test_handle_jwt.py @@ -458,6 +458,118 @@ async def test_auth_builder_non_proxy_admin_user_role(): assert result["user_id"] == "test_user_1" +@pytest.mark.asyncio +@pytest.mark.parametrize( + "row_email,expected_email", + [ + ("row@example.com", "row@example.com"), + (None, "claim@example.com"), + ("", "claim@example.com"), + ], +) +async def test_auth_builder_result_includes_user_email(row_email, expected_email): + """LIT-4238: auth_builder must return user_email (user row wins, JWT claim + is the fallback) so the auth object and metrics get the email.""" + api_key = "test_jwt_token" + request_data = {"model": "gpt-4"} + general_settings = {"enforce_rbac": False} + route = "/chat/completions" + + user_object = LiteLLM_UserTable( + user_id="test_user_1", + user_email=row_email, + user_role=LitellmUserRoles.INTERNAL_USER, + ) + + jwt_handler = JWTHandler() + jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth() + + with ( + patch.object(jwt_handler, "auth_jwt", new_callable=AsyncMock) as mock_auth_jwt, + patch.object(JWTAuthManager, "check_rbac_role", new_callable=AsyncMock), + patch.object(jwt_handler, "get_rbac_role", return_value=None), + patch.object(jwt_handler, "get_scopes", return_value=[]), + patch.object(jwt_handler, "get_object_id", return_value=None), + patch.object( + JWTAuthManager, + "get_user_info", + new_callable=AsyncMock, + return_value=("test_user_1", "claim@example.com", True), + ), + patch.object(jwt_handler, "get_org_id", return_value=None), + patch.object(jwt_handler, "get_end_user_id", return_value=None), + patch.object( + JWTAuthManager, + "check_admin_access", + new_callable=AsyncMock, + return_value=None, + ) as mock_check_admin, + patch.object( + JWTAuthManager, + "find_and_validate_specific_team_id", + new_callable=AsyncMock, + return_value=(None, None), + ), + patch.object(JWTAuthManager, "get_all_team_ids", return_value=set()), + patch.object( + JWTAuthManager, + "find_team_with_model_access", + new_callable=AsyncMock, + return_value=(None, None), + ), + patch.object( + JWTAuthManager, + "get_objects", + new_callable=AsyncMock, + return_value=(user_object, None, None, None, user_object.user_id), + ), + patch.object(JWTAuthManager, "map_user_to_teams", new_callable=AsyncMock), + patch.object(JWTAuthManager, "validate_object_id", return_value=True), + ): + mock_auth_jwt.return_value = {"sub": "test_user_1", "scope": ""} + + result = await JWTAuthManager.auth_builder( + api_key=api_key, + jwt_handler=jwt_handler, + request_data=request_data, + general_settings=general_settings, + route=route, + prisma_client=None, + user_api_key_cache=None, + parent_otel_span=None, + proxy_logging_obj=None, + ) + + assert result["user_email"] == expected_email + assert mock_check_admin.call_args.kwargs["user_email"] == "claim@example.com" + + +@pytest.mark.asyncio +async def test_check_admin_access_result_includes_user_email(): + """LIT-4238: the scope-based admin path has no user row, so the JWT claim + email must ride the JWTAuthBuilderResult.""" + jwt_handler = JWTHandler() + jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth( + admin_jwt_scope="litellm_proxy_admin", + admin_allowed_routes=["/chat/completions"], + ) + + result = await JWTAuthManager.check_admin_access( + jwt_handler=jwt_handler, + scopes=["litellm_proxy_admin"], + route="/chat/completions", + user_id="admin-user", + user_email="admin@example.com", + org_id=None, + api_key="test_jwt_token", + jwt_valid_token={"sub": "admin-user"}, + ) + + assert result is not None + assert result["is_proxy_admin"] is True + assert result["user_email"] == "admin@example.com" + + @pytest.mark.asyncio async def test_sync_user_role_and_teams(): from unittest.mock import MagicMock diff --git a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py index e4c24bf1136..c80a6a1ca9a 100644 --- a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py +++ b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py @@ -1569,6 +1569,7 @@ class TestJWTOAuth2Coexistence: "token": jwt_token, "team_id": "jwt-team", "user_id": "jwt-human-user", + "user_email": None, "end_user_id": None, "org_id": None, "team_membership": None, @@ -1643,6 +1644,7 @@ class TestJWTOAuth2Coexistence: "token": jwt_token, "team_id": "validated-team", "user_id": "validated-user", + "user_email": "validated@example.com", "end_user_id": "validated-end-user", "org_id": "validated-org", "team_membership": None, @@ -1702,6 +1704,7 @@ class TestJWTOAuth2Coexistence: mock_auto_register.call_args.kwargs["end_user_id"] == "validated-end-user" ) assert result.org_id == "validated-org" + assert result.user_email == "validated@example.com" @pytest.mark.asyncio async def test_routing_override_routes_matching_jwt_to_oauth2(self): @@ -1788,6 +1791,7 @@ class TestJWTOAuth2Coexistence: "token": jwt_token, "team_id": "jwt-team", "user_id": "jwt-user-no-override", + "user_email": None, "end_user_id": None, "org_id": None, "team_membership": None, @@ -1988,6 +1992,7 @@ class TestJWTOAuth2Coexistence: "token": jwt_token, "team_id": "jwt-team", "user_id": "jwt-user-scope-mismatch", + "user_email": None, "end_user_id": None, "org_id": None, "team_membership": None, @@ -2296,6 +2301,7 @@ class TestJWTOAuth2Coexistence: "token": jwt_token, "team_id": None, "user_id": "jwt-admin-user", + "user_email": None, "end_user_id": None, "org_id": None, "team_membership": None, @@ -4288,6 +4294,98 @@ async def test_auth_does_not_rewrite_cached_key_object_back_into_cache(): setattr(_proxy_server_mod, k, v) +class TestJWTAuthUserEmail: + """JWT auth must populate `UserAPIKeyAuth.user_email` (LIT-4238); it feeds + the Prometheus `user_email` label and `user_api_key_user_email` in + StandardLogging/SpendLogs metadata, which were always None for JWT traffic.""" + + def _jwt_request(self, jwt_token): + mock_request = MagicMock() + mock_request.url.path = "/v1/chat/completions" + mock_request.method = "POST" + mock_request.headers = {"authorization": f"Bearer {jwt_token}"} + mock_request.query_params = {} + return mock_request + + async def _run_jwt_auth(self, mock_jwt_result, jwt_token): + with ( + patch( + "litellm.proxy.proxy_server.general_settings", + {"enable_jwt_auth": True}, + ), + patch("litellm.proxy.proxy_server.premium_user", True), + patch("litellm.proxy.proxy_server.master_key", "sk-master"), + patch("litellm.proxy.proxy_server.prisma_client", None), + patch( + "litellm.proxy.auth.user_api_key_auth.JWTAuthManager.auth_builder", + new_callable=AsyncMock, + return_value=mock_jwt_result, + ), + ): + litellm.proxy.proxy_server.jwt_handler.update_environment( + prisma_client=None, + user_api_key_cache=DualCache(), + litellm_jwtauth=LiteLLM_JWTAuth(), + ) + return await user_api_key_auth( + request=self._jwt_request(jwt_token), + api_key=f"Bearer {jwt_token}", + ) + + @pytest.mark.asyncio + async def test_jwt_auth_populates_user_email_on_valid_token(self): + jwt_token = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyMSJ9.signature" + mock_jwt_result = { + "is_proxy_admin": False, + "team_object": None, + "user_object": LiteLLM_UserTable( + user_id="jwt-human-user", + user_email="row@example.com", + user_role=LitellmUserRoles.INTERNAL_USER.value, + ), + "end_user_object": None, + "org_object": None, + "token": jwt_token, + "team_id": None, + "user_id": "jwt-human-user", + "user_email": "resolved@example.com", + "end_user_id": None, + "org_id": None, + "team_membership": None, + "jwt_claims": {"sub": "user1"}, + } + + result = await self._run_jwt_auth(mock_jwt_result, jwt_token) + + assert result.user_id == "jwt-human-user" + assert result.user_email == "resolved@example.com" + + @pytest.mark.asyncio + async def test_jwt_auth_populates_user_email_on_proxy_admin(self): + jwt_token = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyMSJ9.signature" + mock_jwt_result = { + "is_proxy_admin": True, + "team_object": None, + "user_object": None, + "end_user_object": None, + "org_object": None, + "token": jwt_token, + "team_id": None, + "user_id": "jwt-admin-user", + "user_email": "admin@example.com", + "end_user_id": None, + "org_id": None, + "team_membership": None, + "jwt_claims": {"sub": "user1"}, + } + + result = await self._run_jwt_auth(mock_jwt_result, jwt_token) + + assert result.user_role == LitellmUserRoles.PROXY_ADMIN + assert result.user_id == "jwt-admin-user" + assert result.user_email == "admin@example.com" + + class TestCheckKeyModelBudgetWithFallback: """`_check_key_model_budget_with_fallback` must reroute a request to the first configured `budget_fallbacks` entry still within its own budget, diff --git a/ui/litellm-dashboard/src/components/molecules/logo/Logo.test.tsx b/ui/litellm-dashboard/src/components/molecules/logo/Logo.test.tsx index 95221f35b8d..c0b52e03a30 100644 --- a/ui/litellm-dashboard/src/components/molecules/logo/Logo.test.tsx +++ b/ui/litellm-dashboard/src/components/molecules/logo/Logo.test.tsx @@ -20,8 +20,8 @@ describe("Logo", () => { expect(screen.queryByRole("img")).not.toBeInTheDocument(); }); - it("renders a dash avatar when neither provider nor src is given", () => { - render(); + it("renders a dash avatar when src is empty and the label has no characters", () => { + render(); expect(screen.getByText("-")).toBeInTheDocument(); expect(screen.queryByRole("img")).not.toBeInTheDocument(); }); diff --git a/ui/litellm-dashboard/src/components/molecules/logo/Logo.tsx b/ui/litellm-dashboard/src/components/molecules/logo/Logo.tsx index 6f18c89ae8e..f5fb1f0805a 100644 --- a/ui/litellm-dashboard/src/components/molecules/logo/Logo.tsx +++ b/ui/litellm-dashboard/src/components/molecules/logo/Logo.tsx @@ -2,12 +2,10 @@ import React, { useState } from "react"; import { getProviderLogoAndName } from "@/components/provider_info_helpers"; import { resolveLogoSrc } from "@/lib/assetPaths"; -interface LogoProps { - provider?: string; - src?: string | null; - label?: string; - className?: string; -} +type LogoProps = { className?: string } & ( + | { provider: string; src?: never; label?: string } + | { provider?: never; src: string | null | undefined; label: string } +); export const Logo: React.FC = ({ provider, src, label, className = "w-4 h-4" }) => { const [erroredSrc, setErroredSrc] = useState(null); diff --git a/ui/litellm-dashboard/src/components/page_metadata.ts b/ui/litellm-dashboard/src/components/page_metadata.ts index 845e868b917..0f2ff639bb3 100644 --- a/ui/litellm-dashboard/src/components/page_metadata.ts +++ b/ui/litellm-dashboard/src/components/page_metadata.ts @@ -19,6 +19,7 @@ export const pageDescriptions: Record = { "tool-policies": "Configure tool use policies and permissions", "vector-stores": "Manage vector databases for embeddings", new_usage: "View usage analytics and metrics", + "cost-optimization": "Track and configure cost-saving features: prompt compression, caching, and auto routing", logs: "Access request and response logs", "guardrails-monitor": "Monitor guardrail performance and view logs", users: "Manage internal user accounts and permissions", diff --git a/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx b/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx index 966bd4e262c..777cdc62987 100644 --- a/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx +++ b/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx @@ -114,13 +114,35 @@ describe("provider_info_helpers", () => { }); describe("provider logo bundled assets", () => { - it("should expose every provider logo as a truthy bundled URL, never a raw /ui/assets path", () => { - const logos = Object.values(providerLogoMap); - expect(logos.length).toBeGreaterThan(0); - logos.forEach((logo) => { - expect(typeof logo).toBe("string"); - expect(logo.length).toBeGreaterThan(0); - expect(logo.startsWith("/ui/assets/")).toBe(false); + it("should map every provider to a bundled logo except the known logoless set, never a raw /ui/assets path", () => { + const knownLogolessProviders = [ + Providers.AUTO_ROUTER, + Providers.BYTEZ, + Providers.CLARIFAI, + Providers.COMPACTIFAI, + Providers.DATAROBOT, + Providers.DOCKER_MODEL_RUNNER, + Providers.DOTPROMPT, + Providers.EMPOWER, + Providers.GALADRIEL, + Providers.GradientAI, + Providers.HEROKU, + Providers.LEMONADE, + Providers.LLAMAFILE, + Providers.MARITALK, + Providers.NLP_CLOUD, + Providers.NSCALE, + Providers.OVHCLOUD, + Providers.PETALS, + Providers.PG_VECTOR, + Providers.PREDIBASE, + Providers.WANDB, + Providers.ZAI, + ]; + const logolessProviders = Object.values(Providers).filter((provider) => !providerLogoMap[provider]); + expect([...logolessProviders].sort()).toEqual([...knownLogolessProviders].sort()); + Object.values(providerLogoMap).forEach((logo) => { + expect(logo?.startsWith("/ui/assets/")).toBe(false); }); }); diff --git a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx index f8be4511e3a..fa6b3c79230 100644 --- a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx +++ b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx @@ -283,7 +283,7 @@ export const provider_map: Record = { const standaloneSubproviderSlugs = new Set(["bedrock_mantle"]); -export const providerLogoMap: Record = { +export const providerLogoMap: Partial> = { [Providers.A2A_Agent]: a2aAgentLogo.src, [Providers.AI21]: ai21Logo.src, [Providers.AI21_CHAT]: ai21Logo.src, @@ -395,7 +395,7 @@ export const getProviderLogoAndName = (providerValue: string): { logo: string; d // Get the display name from Providers enum and logo from map const displayName = Providers[enumKey as keyof typeof Providers]; - const logo = resolveLogoSrc(providerLogoMap[displayName as keyof typeof providerLogoMap]) ?? ""; + const logo = resolveLogoSrc(providerLogoMap[displayName]) ?? ""; return { logo, displayName }; }; diff --git a/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx b/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx index c2dfeb295f2..fef708c24a2 100644 --- a/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx @@ -277,6 +277,13 @@ vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ useOrganizations: vi.fn().mockReturnValue({ data: [], refetch: vi.fn() }), })); +vi.mock("@/app/(dashboard)/hooks/keys/useSetKeyBlockedState", () => ({ + useSetKeyBlockedState: vi.fn().mockReturnValue({ + mutate: vi.fn(), + isPending: false, + }), +})); + // useQueryClient also needs a provider; the delete-path invalidation is covered in key_info_view.test.tsx vi.mock("@tanstack/react-query", async (importOriginal) => { const actual = await importOriginal(); diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx index f8b8e5b6de3..8d7c8e76011 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx @@ -662,11 +662,14 @@ describe("KeyEditView", () => { }); }); - it("should resend existing budget windows on submit when they are left untouched", async () => { + it("should omit budget_limits when existing windows are left untouched (issue #33246)", async () => { + // The backend treats any budget_limits in the payload as an admin-only + // budget change, so re-sending untouched windows 403s a non-admin owner. + // Leaving the field off keeps the stored windows and passes the gate. const onSubmitMock = vi.fn().mockResolvedValue(undefined); const keyDataWithWindow = { ...MOCK_KEY_DATA, - budget_limits: [{ budget_duration: "30d", max_budget: 100 }], + budget_limits: [{ budget_duration: "30d", max_budget: 100, reset_at: "2026-08-01T00:00:00" }], }; renderWithProviders( { await waitFor(() => { expect(onSubmitMock).toHaveBeenCalled(); const callArgs = onSubmitMock.mock.calls[0][0]; - expect(callArgs.budget_limits).toEqual([{ budget_duration: "30d", max_budget: 100 }]); + expect(callArgs.budget_limits).toBeUndefined(); + }); + }); + + it("should omit budget_limits on a key that has no windows (issue #33246 repro)", async () => { + // Core repro: a non-admin owner edits a non-budget field on a key with no + // budget windows. The form previously always sent budget_limits: [], which + // the backend read as a budget change and rejected. + const onSubmitMock = vi.fn().mockResolvedValue(undefined); + renderWithProviders( + {}} + onSubmit={onSubmitMock} + accessToken={"test-token"} + userID={"test-user"} + userRole={"admin"} + premiumUser={false} + />, + ); + + const submitButton = await screen.findByRole("button", { name: /save changes/i }); + await userEvent.click(submitButton); + + await waitFor(() => { + expect(onSubmitMock).toHaveBeenCalled(); + const callArgs = onSubmitMock.mock.calls[0][0]; + expect(callArgs.budget_limits).toBeUndefined(); + }); + }); + + it("should send budget_limits when a window's cap is changed", async () => { + const onSubmitMock = vi.fn().mockResolvedValue(undefined); + const keyDataWithWindow = { + ...MOCK_KEY_DATA, + budget_limits: [{ budget_duration: "30d", max_budget: 100 }], + }; + renderWithProviders( + {}} + onSubmit={onSubmitMock} + accessToken={"test-token"} + userID={"test-user"} + userRole={"admin"} + premiumUser={false} + />, + ); + + const maxBudgetInput = await screen.findByPlaceholderText("Max spend ($)"); + await userEvent.clear(maxBudgetInput); + await userEvent.type(maxBudgetInput, "200"); + + const submitButton = screen.getByRole("button", { name: /save changes/i }); + await userEvent.click(submitButton); + + await waitFor(() => { + expect(onSubmitMock).toHaveBeenCalled(); + const callArgs = onSubmitMock.mock.calls[0][0]; + expect(callArgs.budget_limits).toEqual([{ budget_duration: "30d", max_budget: 200 }]); }); }); diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx index 29998a7aed3..fd5a1295b69 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx @@ -311,14 +311,29 @@ export function KeyEditView({ } // Reconcile multi-window budget limits from the editor state, dropping - // incomplete entries (no max_budget). Sending [] tells the backend to clear - // all stored windows, so only send it when the user removed every window; - // when entries remain but are still incomplete, omit the field so the saved - // windows are left untouched (JSON.stringify drops the undefined key). + // incomplete entries (no max_budget). The backend treats any budget_limits + // in a /key/update request as an admin-only budget change, so re-sending + // the stored windows on an unrelated edit 403s a non-admin key owner + // (issue #33246). Only send the field when the user actually changed the + // windows, mirroring how allowed_routes is dropped above when unchanged: + // compare on (duration, cap), ignoring server-owned reset_at and order. + // Sending [] clears every window, so send it only when the user removed + // the last one; otherwise leave the field off (JSON.stringify drops the + // undefined key) so an unchanged or incomplete editor state never touches + // storage. + const windowSignature = (windows: Array<{ budget_duration: string; max_budget: number | null }> | undefined) => + (windows ?? []) + .filter((w) => w.budget_duration && w.max_budget !== null && w.max_budget !== undefined) + .map((w) => `${w.budget_duration}:${w.max_budget}`) + .sort() + .join("|"); const validWindows = budgetLimits.filter( (w) => w.budget_duration && w.max_budget !== null && w.max_budget !== undefined, ); - if (validWindows.length > 0) { + const budgetLimitsUnchanged = windowSignature(keyData.budget_limits) === windowSignature(validWindows); + if (budgetLimitsUnchanged) { + // no-op: leave budget_limits off the payload + } else if (validWindows.length > 0) { values.budget_limits = validWindows; } else if (budgetLimits.length === 0) { values.budget_limits = []; diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx index 5e9156fcacf..e46c6fd5c30 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx @@ -147,6 +147,10 @@ describe("KeyInfoView", () => { showSSOBanner: false, }; + const openMoreKeyActions = async () => { + await userEvent.click(await screen.findByRole("button", { name: /more key actions/i })); + }; + it("should render tags", async () => { vi.mocked(useAuthorized).mockReturnValue(baseUseAuthorizedMock); @@ -203,8 +207,9 @@ describe("KeyInfoView", () => { await waitFor(() => { expect(screen.getByText("Regenerate Key")).toBeInTheDocument(); - expect(screen.getByText("Delete Key")).toBeInTheDocument(); }); + await openMoreKeyActions(); + expect(await screen.findByRole("menuitem", { name: /delete key/i })).toBeInTheDocument(); }); it("should allow team admin to modify key", async () => { @@ -248,8 +253,9 @@ describe("KeyInfoView", () => { await waitFor(() => { expect(screen.getByText("Regenerate Key")).toBeInTheDocument(); - expect(screen.getByText("Delete Key")).toBeInTheDocument(); }); + await openMoreKeyActions(); + expect(await screen.findByRole("menuitem", { name: /delete key/i })).toBeInTheDocument(); }); it("should allow owner to modify their own key", async () => { @@ -272,8 +278,9 @@ describe("KeyInfoView", () => { await waitFor(() => { expect(screen.getByText("Regenerate Key")).toBeInTheDocument(); - expect(screen.getByText("Delete Key")).toBeInTheDocument(); }); + await openMoreKeyActions(); + expect(await screen.findByRole("menuitem", { name: /delete key/i })).toBeInTheDocument(); }); it("should not allow other user to modify key", async () => { @@ -295,7 +302,7 @@ describe("KeyInfoView", () => { await waitFor(() => { expect(screen.queryByText("Regenerate Key")).not.toBeInTheDocument(); - expect(screen.queryByText("Delete Key")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /more key actions/i })).not.toBeInTheDocument(); }); }); @@ -319,7 +326,7 @@ describe("KeyInfoView", () => { await waitFor(() => { expect(screen.queryByText("Regenerate Key")).not.toBeInTheDocument(); - expect(screen.queryByText("Delete Key")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /more key actions/i })).not.toBeInTheDocument(); }); }); @@ -363,7 +370,7 @@ describe("KeyInfoView", () => { await waitFor(() => { expect(screen.queryByText("Regenerate Key")).not.toBeInTheDocument(); - expect(screen.queryByText("Delete Key")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /more key actions/i })).not.toBeInTheDocument(); }); }); @@ -573,9 +580,8 @@ describe("KeyInfoView", () => { />, ); - await waitFor(() => { - expect(screen.getByRole("button", { name: /reset spend/i })).toBeInTheDocument(); - }); + await openMoreKeyActions(); + expect(await screen.findByRole("menuitem", { name: /reset spend/i })).toBeInTheDocument(); }); it("should show Reset Spend button for team admin of key's team", async () => { @@ -614,9 +620,8 @@ describe("KeyInfoView", () => { />, ); - await waitFor(() => { - expect(screen.getByRole("button", { name: /reset spend/i })).toBeInTheDocument(); - }); + await openMoreKeyActions(); + expect(await screen.findByRole("menuitem", { name: /reset spend/i })).toBeInTheDocument(); }); it("should not show Reset Spend button for regular key owner", async () => { @@ -638,9 +643,9 @@ describe("KeyInfoView", () => { />, ); - await waitFor(() => { - expect(screen.queryByRole("button", { name: /reset spend/i })).not.toBeInTheDocument(); - }); + await openMoreKeyActions(); + expect(await screen.findByRole("menuitem", { name: /delete key/i })).toBeInTheDocument(); + expect(screen.queryByRole("menuitem", { name: /reset spend/i })).not.toBeInTheDocument(); }); }); @@ -663,11 +668,8 @@ describe("KeyInfoView", () => { />, ); - await waitFor(() => { - expect(screen.getByRole("button", { name: /reset spend/i })).toBeInTheDocument(); - }); - - await userEvent.click(screen.getByRole("button", { name: /reset spend/i })); + await openMoreKeyActions(); + await userEvent.click(await screen.findByRole("menuitem", { name: /reset spend/i })); await waitFor(() => { expect(screen.getByText("Reset Key Spend")).toBeInTheDocument(); @@ -694,11 +696,8 @@ describe("KeyInfoView", () => { />, ); - await waitFor(() => { - expect(screen.getByRole("button", { name: /reset spend/i })).toBeInTheDocument(); - }); - - await userEvent.click(screen.getByRole("button", { name: /reset spend/i })); + await openMoreKeyActions(); + await userEvent.click(await screen.findByRole("menuitem", { name: /reset spend/i })); await waitFor(() => { expect(screen.getByText("Reset Key Spend")).toBeInTheDocument(); @@ -808,7 +807,8 @@ describe("KeyInfoView", () => { />, ); - await userEvent.click(await screen.findByRole("button", { name: /delete key/i })); + await openMoreKeyActions(); + await userEvent.click(await screen.findByRole("menuitem", { name: /delete key/i })); const confirmInput = await screen.findByPlaceholderText(MOCK_KEY_DATA.key_alias); await userEvent.type(confirmInput, MOCK_KEY_DATA.key_alias); diff --git a/ui/litellm-dashboard/src/components/vector_store_providers.tsx b/ui/litellm-dashboard/src/components/vector_store_providers.tsx index 03878d5d6f1..d8ee2eb4720 100644 --- a/ui/litellm-dashboard/src/components/vector_store_providers.tsx +++ b/ui/litellm-dashboard/src/components/vector_store_providers.tsx @@ -26,12 +26,12 @@ export const vectorStoreProviderMap: Record = { }; export const vectorStoreProviderLogoMap: Record = { - [VectorStoreProviders.Bedrock]: providerLogoMap[Providers.Bedrock], + [VectorStoreProviders.Bedrock]: providerLogoMap[Providers.Bedrock] ?? "", [VectorStoreProviders.PgVector]: postgresqlLogo.src, - [VectorStoreProviders.VertexRagEngine]: providerLogoMap[Providers.Vertex_AI], - [VectorStoreProviders.VertexAiSearch]: providerLogoMap[Providers.Vertex_AI], - [VectorStoreProviders.OpenAI]: providerLogoMap[Providers.OpenAI], - [VectorStoreProviders.Azure]: providerLogoMap[Providers.Azure], + [VectorStoreProviders.VertexRagEngine]: providerLogoMap[Providers.Vertex_AI] ?? "", + [VectorStoreProviders.VertexAiSearch]: providerLogoMap[Providers.Vertex_AI] ?? "", + [VectorStoreProviders.OpenAI]: providerLogoMap[Providers.OpenAI] ?? "", + [VectorStoreProviders.Azure]: providerLogoMap[Providers.Azure] ?? "", [VectorStoreProviders.Milvus]: milvusLogo.src, [VectorStoreProviders.S3Vectors]: s3VectorLogo.src, }; diff --git a/ui/litellm-dashboard/src/lib/assetPaths.test.ts b/ui/litellm-dashboard/src/lib/assetPaths.test.ts index 0ca6c055a0a..adadef5d274 100644 --- a/ui/litellm-dashboard/src/lib/assetPaths.test.ts +++ b/ui/litellm-dashboard/src/lib/assetPaths.test.ts @@ -48,7 +48,7 @@ describe("resolveLogoSrc", () => { expect(resolveLogoSrc("//cdn.example.com/x.svg")).toBe("//cdn.example.com/x.svg"); }); - it("passes bundled /_next/ asset URLs through untouched even under a sub-path mount", async () => { + it("passes bundled /_next/static/ asset URLs through untouched even under a sub-path mount", async () => { const { resolveLogoSrc } = await importWithRoot("/litellm"); expect(resolveLogoSrc("/_next/static/media/openai_small.abc123.svg")).toBe( "/_next/static/media/openai_small.abc123.svg", @@ -56,6 +56,14 @@ describe("resolveLogoSrc", () => { expect(resolveLogoSrc("/litellm-asset-prefix/_next/static/media/openai_small.abc123.svg")).toBe( "/litellm-asset-prefix/_next/static/media/openai_small.abc123.svg", ); + expect(resolveLogoSrc("/litellm/_next/static/media/openai_small.abc123.svg")).toBe( + "/litellm/_next/static/media/openai_small.abc123.svg", + ); + }); + + it("still prefixes a backend path that merely contains a /_next/ lookalike segment", async () => { + const { resolveLogoSrc } = await importWithRoot("/litellm"); + expect(resolveLogoSrc("/ui/assets/logos/_next/logo.svg")).toBe("/litellm/ui/assets/logos/_next/logo.svg"); }); it("roots a local asset path using the live server root path", async () => { diff --git a/ui/litellm-dashboard/src/lib/assetPaths.ts b/ui/litellm-dashboard/src/lib/assetPaths.ts index ec359ff2d86..abce69127cb 100644 --- a/ui/litellm-dashboard/src/lib/assetPaths.ts +++ b/ui/litellm-dashboard/src/lib/assetPaths.ts @@ -23,7 +23,7 @@ export const withServerRoot = (path: string, root: string): string => { export const resolveLogoSrc = (value: string | null | undefined, root: string = serverRootPath): string | undefined => { if (!value) return undefined; if (EXTERNAL_SRC.test(value)) return value; - if (value.includes("/_next/")) return value; + if (value.includes("/_next/static/")) return value; const prefix = normalizeRootPath(root); if (prefix && (value === prefix || value.startsWith(`${prefix}/`))) return value; return withServerRoot(value, root);