diff --git a/.circleci/config.yml b/.circleci/config.yml index a8a33335ad7..dbeb412506f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -2690,6 +2690,122 @@ jobs: path: ui/litellm-dashboard/playwright-report destination: e2e-playwright-report + e2e_ui_testing_server_root_path: + docker: + - image: cimg/python:3.12-browsers@sha256:b432899af01c9a311bf74f4f22e9ada2e5306d4b1b4383f8d29e1228a5844ef2 + auth: + username: ${DOCKERHUB_USERNAME} + password: ${DOCKERHUB_PASSWORD} + - image: cimg/postgres:16.0@sha256:b125148bc76e8e8eee5eb3ad6020a3a14110a14e8192f1c645128afebe2e2f84 + environment: + POSTGRES_USER: e2euser + POSTGRES_PASSWORD: e2epassword + POSTGRES_DB: litellm_e2e + resource_class: large + working_directory: ~/project + environment: + DATABASE_URL: "postgresql://e2euser:e2epassword@localhost:5432/litellm_e2e" + CI: "true" + # The whole job exercises the proxy mounted under a prefix. SERVER_ROOT_PATH + # is read both by the proxy at boot (to rewrite the built UI bundle in place) + # and by migration.serverRootPath.config.ts, which refuses to run without it. + SERVER_ROOT_PATH: "/litellm" + steps: + - checkout + - setup_google_dns + - install_uv + - restore_cache: + keys: + - v1-uv-cache-{{ checksum "uv.lock" }} + - run: + name: Install Python dependencies + command: | + uv sync --frozen --all-groups --all-extras --python 3.12 + uv run --no-sync python -m prisma generate --schema litellm/proxy/schema.prisma + - save_cache: + key: v1-uv-cache-{{ checksum "uv.lock" }} + paths: + - ~/.cache/uv + - restore_cache: + keys: + - ui-e2e-node-deps-v2-{{ checksum "ui/litellm-dashboard/package-lock.json" }} + - run: + name: Install Node dependencies and Playwright + command: | + cd ui/litellm-dashboard + npm ci + npx playwright install chromium + - save_cache: + key: ui-e2e-node-deps-v2-{{ checksum "ui/litellm-dashboard/package-lock.json" }} + paths: + - ui/litellm-dashboard/node_modules + - ~/.cache/ms-playwright + - run: + name: Build UI from source + command: | + cd ui/litellm-dashboard + npm run build + rm -rf ../../litellm/proxy/_experimental/out + mv out ../../litellm/proxy/_experimental/out + find ../../litellm/proxy/_experimental/out -name '*.html' ! -name 'index.html' | while read -r f; do + d="${f%.html}"; mkdir -p "$d"; mv "$f" "$d/index.html" + done + - wait_for_service: + url: tcp://localhost:5432 + timeout: "30" + - run: + name: Push Prisma schema + command: uv run --no-sync python -m prisma db push --schema litellm/proxy/schema.prisma --accept-data-loss + - run: + name: Seed database + command: | + PGPASSWORD=e2epassword psql -h localhost -p 5432 -U e2euser -d litellm_e2e \ + -f ui/litellm-dashboard/e2e_tests/fixtures/seed.sql + - run: + name: Start mock LLM server + command: uv run --no-sync python ui/litellm-dashboard/e2e_tests/fixtures/mock_llm_server/server.py + background: true + - run: + name: Start LiteLLM proxy under a server root path + environment: + LITELLM_MASTER_KEY: "sk-1234" + MOCK_LLM_URL: "http://127.0.0.1:8090/v1" + DISABLE_SCHEMA_UPDATE: "true" + # Output flows to this step's own log, so a boot crash is visible here + # rather than swallowed by a downstream readiness probe. + command: | + LITELLM_LICENSE="$LITELLM_LICENSE" \ + uv run --no-sync python -m litellm.proxy.proxy_cli \ + --config ui/litellm-dashboard/e2e_tests/fixtures/config.yml \ + --port 4000 + background: true + - run: + name: Wait for prefixed proxy to be ready + command: | + for i in $(seq 1 60); do + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 -H "Authorization: Bearer sk-1234" http://127.0.0.1:4000/litellm/health 2>/dev/null || true) + if [ "$HTTP_CODE" = "200" ]; then + echo "Prefixed proxy is ready" + exit 0 + fi + sleep 2 + done + echo "Prefixed proxy failed to start; see the 'Start LiteLLM proxy under a server root path' step for the boot log" + exit 1 + - run: + name: Run migration smoke under SERVER_ROOT_PATH + command: | + cd ui/litellm-dashboard + LITELLM_LICENSE="$LITELLM_LICENSE" \ + npx playwright test --config e2e_tests/migration.serverRootPath.config.ts + no_output_timeout: 10m + - store_artifacts: + path: ui/litellm-dashboard/test-results + destination: e2e-server-root-path-test-results + - store_artifacts: + path: ui/litellm-dashboard/playwright-report + destination: e2e-server-root-path-playwright-report + build_docker_database_image: machine: image: ubuntu-2204:2024.04.1 @@ -2795,6 +2911,8 @@ workflows: filters: *main_branches - e2e_ui_testing: filters: *main_branches + - e2e_ui_testing_server_root_path: + filters: *main_branches - build_and_test: requires: - build_docker_database_image diff --git a/litellm/constants.py b/litellm/constants.py index f10cec034f0..57f55e6c177 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -1158,6 +1158,7 @@ "openai.gpt-oss-120b-1:0", "anthropic.claude-haiku-4-5-20251001-v1:0", "anthropic.claude-sonnet-4-5-20250929-v1:0", + "anthropic.claude-fable-5", "anthropic.claude-opus-4-8", "anthropic.claude-opus-4-7", "anthropic.claude-opus-4-6-v1:0", diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 3f30d5d6807..9ecd0df0cb8 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -1455,10 +1455,15 @@ def map_openai_params( # noqa: PLR0915 _value = self._map_stop_sequences(value) if _value is not None: optional_params["stop_sequences"] = _value - elif param == "temperature": - optional_params["temperature"] = value - elif param == "top_p": - optional_params["top_p"] = value + elif param == "temperature" or param == "top_p": + AnthropicConfig._apply_sampling_param( + optional_params=optional_params, + model=model, + param=param, + value=value, + drop_params=drop_params, + output_key=param, + ) elif param == "response_format" and isinstance(value, dict): if any( substring in model @@ -1975,6 +1980,20 @@ def transform_request( optional_params.pop("is_vertex_request", None) optional_params.pop("client_metadata", None) + # ``top_k`` is a provider-specific kwarg that bypasses + # ``map_openai_params``; gate it here, the single boundary shared by + # the direct Anthropic, Bedrock invoke, Vertex, and Azure paths. + top_k = optional_params.pop("top_k", None) + if top_k is not None: + AnthropicConfig._apply_sampling_param( + optional_params=optional_params, + model=model, + param="top_k", + value=top_k, + drop_params=litellm_params.get("drop_params") is True, + output_key="top_k", + ) + data = { "model": model, "messages": anthropic_messages, diff --git a/litellm/llms/anthropic/common_utils.py b/litellm/llms/anthropic/common_utils.py index 3f002d73cbc..5741513903c 100644 --- a/litellm/llms/anthropic/common_utils.py +++ b/litellm/llms/anthropic/common_utils.py @@ -272,23 +272,68 @@ def _is_claude_4_7_model(model: str) -> bool: ) @staticmethod - def _supports_model_capability(model: str, key: str) -> bool: - """Check a boolean capability ``key`` in the model map. + def _supports_sampling_params(model: str) -> bool: + """Claude 4.7+ (Opus 4.7/4.8, Fable 5) removed sampling params: the API + rejects ``top_p``, ``top_k``, and any ``temperature`` other than 1 with + a 400 ("`temperature` is deprecated for this model"). + + Driven by the ``supports_sampling_params`` flag in the model map; the + name check remains only as a fallback for provider-routed ids whose + map entries predate the flag.""" + flag = AnthropicModelInfo._get_model_capability( + model, "supports_sampling_params" + ) + if flag is not None: + return flag + model_lower = model.lower() + return not any( + v in model_lower + for v in ( + "fable", + "opus-4-7", + "opus_4_7", + "opus-4.7", + "opus_4.7", + "opus-4-8", + "opus_4_8", + "opus-4.8", + "opus_4.8", + ) + ) - Strips bedrock/vertex prefixes so a provider-routed Claude still - resolves to the Anthropic model-map entry. - """ - from litellm.utils import _supports_factory + @staticmethod + def _apply_sampling_param( + optional_params: dict, + model: str, + param: str, + value: Any, + drop_params: bool, + output_key: str, + ) -> None: + """Forward ``temperature``/``top_p``/``top_k`` to + ``optional_params[output_key]`` unless the model removed sampling + params, in which case drop the param (with drop_params) or raise a + clean client-side 400.""" + if AnthropicModelInfo._supports_sampling_params(model) or ( + param == "temperature" and value == 1 + ): + optional_params[output_key] = value + elif not (litellm.drop_params or drop_params): + supported_hint = ( + "Only temperature=1 is supported. " if param == "temperature" else "" + ) + raise litellm.utils.UnsupportedParamsError( + message=( + f"{model} does not support {param}={value}. {supported_hint}" + "To drop unsupported params, set `litellm.drop_params = True`." + ), + status_code=400, + ) - try: - if _supports_factory( - model=model, - custom_llm_provider="anthropic", - key=key, - ): - return True - except Exception: - pass + @staticmethod + def _model_map_lookup_candidates(model: str) -> List[str]: + """Model-map keys to try for ``model``, stripping bedrock/vertex + prefixes so a provider-routed Claude still resolves to its entry.""" candidates = [model] for prefix in ( "bedrock/converse/", @@ -307,15 +352,40 @@ def _supports_model_capability(model: str, key: str) -> bool: candidates.append(f"bedrock/{base}") except Exception: pass + return candidates + + @staticmethod + def _get_model_capability(model: str, key: str) -> Optional[bool]: + """Read boolean capability ``key`` from the model map, or None when + no entry declares it.""" try: - for cand in candidates: - if cand in litellm.model_cost and ( - litellm.model_cost[cand].get(key) is True - ): - return True + for cand in AnthropicModelInfo._model_map_lookup_candidates(model): + value = litellm.model_cost.get(cand, {}).get(key) + if isinstance(value, bool): + return value except Exception: pass - return False + return None + + @staticmethod + def _supports_model_capability(model: str, key: str) -> bool: + """Check a boolean capability ``key`` in the model map. + + Strips bedrock/vertex prefixes so a provider-routed Claude still + resolves to the Anthropic model-map entry. + """ + from litellm.utils import _supports_factory + + try: + if _supports_factory( + model=model, + custom_llm_provider="anthropic", + key=key, + ): + return True + except Exception: + pass + return AnthropicModelInfo._get_model_capability(model, key) is True @staticmethod def _is_adaptive_thinking_model(model: str) -> bool: diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index ea0326dffd1..b5e5e4de6fc 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -920,10 +920,15 @@ def map_openai_params( continue value = [value] optional_params["stopSequences"] = value - if param == "temperature": - optional_params["temperature"] = value - if param == "top_p": - optional_params["topP"] = value + if param == "temperature" or param == "top_p": + AnthropicConfig._apply_sampling_param( + optional_params=optional_params, + model=model, + param=param, + value=value, + drop_params=drop_params, + output_key="topP" if param == "top_p" else param, + ) if param == "tools" and isinstance(value, list): self._apply_tool_call_transformation( tools=cast(List[OpenAIChatCompletionToolParam], value), @@ -1221,7 +1226,9 @@ def _transform_inference_params(self, inference_params: dict) -> InferenceConfig inference_params["topK"] = inference_params.pop("top_k") return InferenceConfig(**inference_params) - def _handle_top_k_value(self, model: str, inference_params: dict) -> dict: + def _handle_top_k_value( + self, model: str, inference_params: dict, drop_params: bool = False + ) -> dict: base_model = BedrockModelInfo.get_base_model(model) val_top_k = None @@ -1230,16 +1237,25 @@ def _handle_top_k_value(self, model: str, inference_params: dict) -> dict: elif "top_k" in inference_params: val_top_k = inference_params.pop("top_k") - if val_top_k: + if val_top_k is not None: if base_model.startswith("anthropic"): - return {"top_k": val_top_k} + top_k_params: dict = {} + AnthropicConfig._apply_sampling_param( + optional_params=top_k_params, + model=model, + param="top_k", + value=val_top_k, + drop_params=drop_params, + output_key="top_k", + ) + return top_k_params if base_model.startswith("amazon.nova"): return {"inferenceConfig": {"topK": val_top_k}} return {} def _prepare_request_params( - self, optional_params: dict, model: str + self, optional_params: dict, model: str, drop_params: bool = False ) -> Tuple[dict, dict, dict, Optional[OutputConfigBlock]]: """Prepare and separate request parameters.""" # Consume the internal ``_output_config_normalized`` marker set by @@ -1338,7 +1354,7 @@ def _prepare_request_params( # Only set the topK value in for models that support it additional_request_params.update( - self._handle_top_k_value(model, inference_params) + self._handle_top_k_value(model, inference_params, drop_params) ) # Filter out internal/MCP-related parameters that shouldn't be sent to the API @@ -1572,6 +1588,7 @@ def _transform_request_helper( optional_params: dict, messages: Optional[List[AllMessageValues]] = None, headers: Optional[dict] = None, + drop_params: bool = False, ) -> CommonRequestObject: ## VALIDATE REQUEST """ @@ -1618,7 +1635,7 @@ def _transform_request_helper( additional_request_params, request_metadata, output_config, - ) = self._prepare_request_params(optional_params, model) + ) = self._prepare_request_params(optional_params, model, drop_params) original_tools = inference_params.pop("tools", []) @@ -1701,6 +1718,7 @@ async def _async_transform_request( optional_params=optional_params, messages=messages, headers=headers, + drop_params=litellm_params.get("drop_params") is True, ) bedrock_messages = ( @@ -1758,6 +1776,7 @@ def _transform_request( optional_params=optional_params, messages=messages, headers=headers, + drop_params=litellm_params.get("drop_params") is True, ) ## TRANSFORMATION ## diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 282a292ab17..3782da1350f 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -1156,6 +1156,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -1202,6 +1203,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -1233,6 +1235,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -1264,6 +1267,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -1295,6 +1299,139 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh" + }, + "anthropic.claude-fable-5": { + "cache_creation_input_token_cost": 1.25e-05, + "cache_creation_input_token_cost_above_1hr": 2e-05, + "cache_read_input_token_cost": 1e-06, + "input_cost_per_token": 1e-05, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh" + }, + "global.anthropic.claude-fable-5": { + "cache_creation_input_token_cost": 1.25e-05, + "cache_creation_input_token_cost_above_1hr": 2e-05, + "cache_read_input_token_cost": 1e-06, + "input_cost_per_token": 1e-05, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh" + }, + "us.anthropic.claude-fable-5": { + "cache_creation_input_token_cost": 1.375e-05, + "cache_creation_input_token_cost_above_1hr": 2.2e-05, + "cache_read_input_token_cost": 1.1e-06, + "input_cost_per_token": 1.1e-05, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh" + }, + "eu.anthropic.claude-fable-5": { + "cache_creation_input_token_cost": 1.375e-05, + "cache_creation_input_token_cost_above_1hr": 2.2e-05, + "cache_read_input_token_cost": 1.1e-06, + "input_cost_per_token": 1.1e-05, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -1327,6 +1464,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -1359,6 +1497,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -1391,6 +1530,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -1423,6 +1563,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -1455,6 +1596,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -1485,6 +1627,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -2208,6 +2351,37 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true + }, + "azure_ai/claude-fable-5": { + "input_cost_per_token": 1e-05, + "output_cost_per_token": 5e-05, + "litellm_provider": "azure_ai", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "cache_creation_input_token_cost": 1.25e-05, + "cache_creation_input_token_cost_above_1hr": 2e-05, + "cache_read_input_token_cost": 1e-06, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -2237,6 +2411,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -10170,6 +10345,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -10204,6 +10380,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -10214,6 +10391,40 @@ }, "supports_output_config": true }, + "claude-fable-5": { + "cache_creation_input_token_cost": 1.25e-05, + "cache_creation_input_token_cost_above_1hr": 2e-05, + "cache_read_input_token_cost": 1e-06, + "input_cost_per_token": 1e-05, + "litellm_provider": "anthropic", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true, + "provider_specific_entry": { + "us": 1.1 + }, + "supports_output_config": true + }, "claude-opus-4-8": { "cache_creation_input_token_cost": 6.25e-06, "cache_creation_input_token_cost_above_1hr": 1e-05, @@ -10238,6 +10449,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -34004,6 +34216,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -34032,6 +34245,67 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true + }, + "vertex_ai/claude-fable-5": { + "cache_creation_input_token_cost": 1.25e-05, + "cache_creation_input_token_cost_above_1hr": 2e-05, + "cache_read_input_token_cost": 1e-06, + "input_cost_per_token": 1e-05, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true + }, + "vertex_ai/claude-fable-5@default": { + "cache_creation_input_token_cost": 1.25e-05, + "cache_creation_input_token_cost_above_1hr": 2e-05, + "cache_read_input_token_cost": 1e-06, + "input_cost_per_token": 1e-05, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -34061,6 +34335,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -34090,6 +34365,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, diff --git a/litellm/proxy/hooks/batch_rate_limiter.py b/litellm/proxy/hooks/batch_rate_limiter.py index 3957e3a7fbb..5b691beccbf 100644 --- a/litellm/proxy/hooks/batch_rate_limiter.py +++ b/litellm/proxy/hooks/batch_rate_limiter.py @@ -531,11 +531,17 @@ async def count_input_file_usage( # Check if this is a managed file (base64 encoded unified file ID) from litellm.proxy.openai_files_endpoints.common_utils import ( _is_base64_encoded_unified_file_id, + get_models_from_unified_file_id, ) # Managed files require bypassing the HTTP endpoint (which runs access-check hooks) # and calling the managed files hook directly with the user's credentials. is_managed_file = _is_base64_encoded_unified_file_id(file_id) + target_model_names = ( + get_models_from_unified_file_id(is_managed_file) + if is_managed_file + else [] + ) if is_managed_file and user_api_key_dict is not None: file_content = await self._fetch_managed_file_content( file_id=file_id, @@ -573,6 +579,7 @@ async def count_input_file_usage( await self._enforce_batch_file_model_access( user_api_key_dict=user_api_key_dict, file_content_as_dict=file_content_as_dict, + target_model_names=target_model_names or None, ) input_file_usage = _get_batch_job_input_file_usage( @@ -608,9 +615,13 @@ async def _enforce_batch_file_model_access( self, user_api_key_dict: UserAPIKeyAuth, file_content_as_dict: List[dict], + target_model_names: Optional[List[str]] = None, ) -> None: - """Reject the batch if the caller is not authorized for every - ``body.model`` named inside the JSONL. + """Reject the batch if the caller is not authorized for the upload target. + + For managed files, ``target_model_names`` (from the unified file id) is + the proxy alias the file was uploaded for and is used directly for auth. + For legacy/non-managed files, falls back to ``body.model`` values in the JSONL. Reuses standard auth helpers so the same model access rules the proxy enforces on `/chat/completions` apply here. @@ -627,9 +638,12 @@ async def _enforce_batch_file_model_access( from litellm.proxy.proxy_server import proxy_logging_obj from litellm.proxy.proxy_server import user_api_key_cache - models = _get_models_from_batch_input_file_content(file_content_as_dict) - if not models: - return + if target_model_names: + models = target_model_names + else: + models = _get_models_from_batch_input_file_content(file_content_as_dict) + if not models: + return team_object = None if ( @@ -660,12 +674,7 @@ async def _enforce_batch_file_model_access( llm_model_list = llm_router.model_list if llm_router is not None else None for model in models: - # body.model may be the provider id after replace_model_in_jsonl; map to proxy model_name for auth. model_to_check = model - if llm_router is not None: - proxy_model_name = llm_router.resolve_model_name_from_model_id(model) - if proxy_model_name is not None: - model_to_check = proxy_model_name try: if team_object is not None: try: diff --git a/litellm/proxy/image_endpoints/endpoints.py b/litellm/proxy/image_endpoints/endpoints.py index fe8b7c6fdc9..c217116e45f 100644 --- a/litellm/proxy/image_endpoints/endpoints.py +++ b/litellm/proxy/image_endpoints/endpoints.py @@ -173,6 +173,16 @@ async def image_generation( ) ) + # Call response headers hook (matches base_process_llm_request behavior) + callback_headers = await proxy_logging_obj.post_call_response_headers_hook( + data=data, + user_api_key_dict=user_api_key_dict, + response=response, + request_headers=dict(request.headers), + ) + if callback_headers: + fastapi_response.headers.update(callback_headers) + return response except Exception as e: await proxy_logging_obj.post_call_failure_hook( diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index c6c14c7a3e1..0df4675b67f 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -21,7 +21,7 @@ import os from dataclasses import dataclass from datetime import datetime, timedelta, timezone -from typing import Any, Dict, Iterable, List, Literal, Optional +from typing import Any, Dict, Iterable, List, Literal, Optional, Set from fastapi import ( APIRouter, @@ -1722,11 +1722,13 @@ async def _get_cached_temporary_mcp_server_or_404( status_code=status.HTTP_403_FORBIDDEN, detail={"error": f"Access denied to MCP server {server_id}"}, ) - allowed_server_ids = ( - await global_mcp_server_manager.get_allowed_mcp_servers( - user_api_key_dict + allowed_server_ids: Set[str] = set() + for auth_context in await build_effective_auth_contexts(user_api_key_dict): + allowed_server_ids.update( + await global_mcp_server_manager.get_allowed_mcp_servers( + auth_context + ) ) - ) if server.server_id not in allowed_server_ids: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 0e69de87ce2..435a8cae379 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -779,55 +779,40 @@ async def _check_user_team_limits( user_api_key_dict: UserAPIKeyAuth, prisma_client: PrismaClient, user_api_key_cache: Any, - existing_team_max_budget: Optional[float] = None, ) -> None: """ - Check user team limits for standalone teams (not org-scoped). + Enforce the caller's personal limits when CREATING a standalone team. - This validates: - - Team budget vs user's max_budget - - Team models vs user's allowed models + This validates the requested team budget / models / tpm / rpm against the + caller's own limits, so a non-admin user cannot mint a brand-new team that + is richer than themselves. - Should only be called for standalone teams (when organization_id is None). - For org-scoped teams, use _check_org_team_limits() instead. - - `existing_team_max_budget` is the team's current `max_budget` on the - /team/update path. When the incoming `max_budget` is unchanged or lower - than the team's current budget, the personal-budget comparison is skipped - so a team admin can edit other fields (e.g. tpm_limit, team name) without - being blocked by a budget the team already has. The UI sends the full team - object on every update, so the unchanged `max_budget` would otherwise fail. + Only used by /team/new for standalone teams (organization_id is None). + /team/update does NOT call this — an existing team's admin is already + authorized via _verify_team_access() and is not gated by their personal + wallet. Org-scoped teams use _check_org_team_limits() instead. """ # Validate team budget against user's max_budget if data.max_budget is not None and user_api_key_dict.user_id is not None: - # On /team/update, allow unchanged or lower budgets without checking - # the caller's personal max_budget. Only increases above the team's - # current budget are validated against the user's personal limit. - budget_unchanged_or_lower = ( - existing_team_max_budget is not None - and data.max_budget <= existing_team_max_budget + user_obj = await get_user_object( + user_id=user_api_key_dict.user_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + user_id_upsert=False, ) - if not budget_unchanged_or_lower: - user_obj = await get_user_object( - user_id=user_api_key_dict.user_id, - prisma_client=prisma_client, - user_api_key_cache=user_api_key_cache, - user_id_upsert=False, + if ( + user_obj is not None + and user_obj.max_budget is not None + and data.max_budget > user_obj.max_budget + ): + raise HTTPException( + status_code=400, + detail={ + "error": f"max budget higher than user max. User max budget={user_obj.max_budget}. User role={user_api_key_dict.user_role}" + }, ) - if ( - user_obj is not None - and user_obj.max_budget is not None - and data.max_budget > user_obj.max_budget - ): - raise HTTPException( - status_code=400, - detail={ - "error": f"max budget higher than user max. User max budget={user_obj.max_budget}. User role={user_api_key_dict.user_role}" - }, - ) - # Validate team models against user's allowed models if data.models is not None and len(user_api_key_dict.models) > 0: for m in data.models: @@ -865,6 +850,45 @@ async def _check_user_team_limits( ) +def _check_team_budget_update_authority( + data: UpdateTeamRequest, + user_api_key_dict: UserAPIKeyAuth, + existing_team_max_budget: Optional[float], +) -> None: + """ + Restrict who can grow a standalone team's spend ceiling on /team/update. + + A team admin (already authorized via _verify_team_access) may keep or lower + the team budget, but only a proxy admin may grow it - by raising max_budget + above the team's current value or by removing the cap (setting it to None). + Setting a finite budget on a team that has no cap is a restriction and is + allowed. Org-scoped teams are governed by _check_org_team_limits(). + """ + if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN: + return + if existing_team_max_budget is None: + return + + budget_explicitly_set = "max_budget" in ( + getattr(data, "model_fields_set", None) or set() + ) + if budget_explicitly_set and data.max_budget is None: + raise HTTPException( + status_code=403, + detail={ + "error": f"Only a proxy admin can remove a team's max_budget. Team's current max_budget={existing_team_max_budget}." + }, + ) + + if data.max_budget is not None and data.max_budget > existing_team_max_budget: + raise HTTPException( + status_code=403, + detail={ + "error": f"Only a proxy admin can raise a team's max_budget. Team's current max_budget={existing_team_max_budget}, requested={data.max_budget}." + }, + ) + + #### TEAM MANAGEMENT #### @router.post( "/team/new", @@ -1827,22 +1851,14 @@ async def update_team( # noqa: PLR0915 prisma_client=prisma_client, ) - # Check user limits for standalone teams (not org-scoped) - # Skip for PROXY_ADMIN users - if ( - user_api_key_dict.user_role is None - or user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN - ): - # Only validate user budget/models for standalone teams - # For org-scoped teams, validation is done by _check_org_team_limits() above - if org_id_to_check is None: - await _check_user_team_limits( - data=data, - user_api_key_dict=user_api_key_dict, - prisma_client=prisma_client, - user_api_key_cache=user_api_key_cache, - existing_team_max_budget=existing_team_row.max_budget, - ) + # Only a proxy admin may grow a standalone team's spend ceiling. + # Org-scoped teams are validated by _check_org_team_limits() above. + if org_id_to_check is None: + _check_team_budget_update_authority( + data=data, + user_api_key_dict=user_api_key_dict, + existing_team_max_budget=existing_team_row.max_budget, + ) updated_kv = data.json(exclude_unset=True) diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index 45e264b1cdd..d2b848e3c33 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -1099,6 +1099,20 @@ async def pass_through_request( # noqa: PLR0915 status_code=e.response.status_code, detail=await e.response.aread() ) + # Call response headers hook for streaming pass-through + _response_headers = HttpPassThroughEndpointHelpers.get_response_headers( + headers=response.headers, + litellm_call_id=litellm_call_id, + ) + callback_headers = await proxy_logging_obj.post_call_response_headers_hook( + data=_parsed_body or {}, + user_api_key_dict=user_api_key_dict, + response=response, + request_headers=dict(request.headers), + ) + if callback_headers: + _response_headers.update(callback_headers) + return StreamingResponse( PassThroughStreamingHandler.chunk_processor( response=response, @@ -1109,10 +1123,7 @@ async def pass_through_request( # noqa: PLR0915 passthrough_success_handler_obj=pass_through_endpoint_logging, url_route=str(url), ), - headers=HttpPassThroughEndpointHelpers.get_response_headers( - headers=response.headers, - litellm_call_id=litellm_call_id, - ), + headers=_response_headers, status_code=response.status_code, ) @@ -1151,6 +1162,20 @@ async def pass_through_request( # noqa: PLR0915 status_code=e.response.status_code, detail=await e.response.aread() ) + # Call response headers hook for detected streaming pass-through + _response_headers = HttpPassThroughEndpointHelpers.get_response_headers( + headers=response.headers, + litellm_call_id=litellm_call_id, + ) + callback_headers = await proxy_logging_obj.post_call_response_headers_hook( + data=_parsed_body or {}, + user_api_key_dict=user_api_key_dict, + response=response, + request_headers=dict(request.headers), + ) + if callback_headers: + _response_headers.update(callback_headers) + return StreamingResponse( PassThroughStreamingHandler.chunk_processor( response=response, @@ -1161,10 +1186,7 @@ async def pass_through_request( # noqa: PLR0915 passthrough_success_handler_obj=pass_through_endpoint_logging, url_route=str(url), ), - headers=HttpPassThroughEndpointHelpers.get_response_headers( - headers=response.headers, - litellm_call_id=litellm_call_id, - ), + headers=_response_headers, status_code=response.status_code, ) @@ -1303,6 +1325,16 @@ async def pass_through_request( # noqa: PLR0915 api_base=str(url._uri_reference), ) + # Call response headers hook + callback_headers = await proxy_logging_obj.post_call_response_headers_hook( + data=_parsed_body or {}, + user_api_key_dict=user_api_key_dict, + response=response, + request_headers=dict(request.headers), + ) + if callback_headers: + custom_headers.update(callback_headers) + response_headers = HttpPassThroughEndpointHelpers.get_response_headers( headers=response.headers, custom_headers=custom_headers, diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 213f682b8f7..37a0285b196 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -4089,6 +4089,8 @@ async def load_config( # noqa: PLR0915 verbose_proxy_logger.debug( f"litellm.post_call_rules: {litellm.post_call_rules}" ) + elif key == "max_budget": + litellm.max_budget = float(value) elif key == "max_internal_user_budget": litellm.max_internal_user_budget = float(value) # type: ignore elif key == "default_max_internal_user_budget": @@ -9191,6 +9193,16 @@ async def audio_speech( hidden_params=hidden_params, ) + # Call response headers hook (matches audio_transcription behavior) + callback_headers = await proxy_logging_obj.post_call_response_headers_hook( + data=data, + user_api_key_dict=user_api_key_dict, + response=response, + request_headers=dict(request.headers), + ) + if callback_headers: + custom_headers.update(callback_headers) + # Determine media type based on model type media_type = "audio/mpeg" # Default for OpenAI TTS request_model = data.get("model", "") diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index ea634289cb4..3a609eec127 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -178,6 +178,11 @@ class UISettings(BaseModel): description="If true, org admins cannot generate API keys via /key/generate.", ) + disable_ui_nudges: bool = Field( + default=False, + description="If true, suppresses in-product UI nudges (survey and Claude Code feedback popups) for all users.", + ) + class UISettingsResponse(SettingsResponse): """Response model for UI settings""" @@ -201,6 +206,7 @@ class UISettingsResponse(SettingsResponse): "scope_user_search_to_org", "disable_custom_api_keys", "disable_key_generate_for_org_admin", + "disable_ui_nudges", } # Flags that must be synced from the persisted UISettings into diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index 5ad42b5e1be..2bba8bbd604 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -2496,7 +2496,8 @@ def _build_litellm_call_info(data: dict, response: Any) -> Dict[str, Any]: ) return { - "custom_llm_provider": hidden_params.get("custom_llm_provider"), + "custom_llm_provider": hidden_params.get("custom_llm_provider") + or getattr(response, "custom_llm_provider", None), "model_info": model_info, "api_base": hidden_params.get("api_base"), "model_id": hidden_params.get("model_id"), diff --git a/litellm/setup_wizard.py b/litellm/setup_wizard.py index 862ca13e7ba..2f0cb1233ae 100644 --- a/litellm/setup_wizard.py +++ b/litellm/setup_wizard.py @@ -52,11 +52,12 @@ { "id": "anthropic", "name": "Anthropic", - "description": "Claude Opus 4.8, Opus 4.7, Opus 4.6, Sonnet 4.6, Haiku 4.5", + "description": "Claude Fable 5, Opus 4.8, Opus 4.7, Opus 4.6, Sonnet 4.6, Haiku 4.5", "env_key": "ANTHROPIC_API_KEY", "key_hint": "sk-ant-...", "test_model": "claude-haiku-4-5-20251001", "models": [ + "claude-fable-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index b0ffc66d03b..85cb06b7f19 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -1156,6 +1156,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -1202,6 +1203,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -1233,6 +1235,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -1264,6 +1267,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -1295,6 +1299,139 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh" + }, + "anthropic.claude-fable-5": { + "cache_creation_input_token_cost": 1.25e-05, + "cache_creation_input_token_cost_above_1hr": 2e-05, + "cache_read_input_token_cost": 1e-06, + "input_cost_per_token": 1e-05, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh" + }, + "global.anthropic.claude-fable-5": { + "cache_creation_input_token_cost": 1.25e-05, + "cache_creation_input_token_cost_above_1hr": 2e-05, + "cache_read_input_token_cost": 1e-06, + "input_cost_per_token": 1e-05, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh" + }, + "us.anthropic.claude-fable-5": { + "cache_creation_input_token_cost": 1.375e-05, + "cache_creation_input_token_cost_above_1hr": 2.2e-05, + "cache_read_input_token_cost": 1.1e-06, + "input_cost_per_token": 1.1e-05, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh" + }, + "eu.anthropic.claude-fable-5": { + "cache_creation_input_token_cost": 1.375e-05, + "cache_creation_input_token_cost_above_1hr": 2.2e-05, + "cache_read_input_token_cost": 1.1e-06, + "input_cost_per_token": 1.1e-05, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -1327,6 +1464,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -1359,6 +1497,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -1391,6 +1530,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -1423,6 +1563,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -1455,6 +1596,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -1485,6 +1627,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -2208,6 +2351,37 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true + }, + "azure_ai/claude-fable-5": { + "input_cost_per_token": 1e-05, + "output_cost_per_token": 5e-05, + "litellm_provider": "azure_ai", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "cache_creation_input_token_cost": 1.25e-05, + "cache_creation_input_token_cost_above_1hr": 2e-05, + "cache_read_input_token_cost": 1e-06, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -2237,6 +2411,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -10170,6 +10345,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -10204,6 +10380,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -10214,6 +10391,40 @@ }, "supports_output_config": true }, + "claude-fable-5": { + "cache_creation_input_token_cost": 1.25e-05, + "cache_creation_input_token_cost_above_1hr": 2e-05, + "cache_read_input_token_cost": 1e-06, + "input_cost_per_token": 1e-05, + "litellm_provider": "anthropic", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true, + "provider_specific_entry": { + "us": 1.1 + }, + "supports_output_config": true + }, "claude-opus-4-8": { "cache_creation_input_token_cost": 6.25e-06, "cache_creation_input_token_cost_above_1hr": 1e-05, @@ -10238,6 +10449,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -34044,6 +34256,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -34072,6 +34285,67 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true + }, + "vertex_ai/claude-fable-5": { + "cache_creation_input_token_cost": 1.25e-05, + "cache_creation_input_token_cost_above_1hr": 2e-05, + "cache_read_input_token_cost": 1e-06, + "input_cost_per_token": 1e-05, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true + }, + "vertex_ai/claude-fable-5@default": { + "cache_creation_input_token_cost": 1.25e-05, + "cache_creation_input_token_cost_above_1hr": 2e-05, + "cache_read_input_token_cost": 1e-06, + "input_cost_per_token": 1e-05, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -34101,6 +34375,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -34130,6 +34405,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, diff --git a/security.md b/security.md index c6cd64ddaac..cb5eda7ee22 100644 --- a/security.md +++ b/security.md @@ -3,12 +3,20 @@ ## Security Vulnerability Reporting Guidelines +> [!WARNING] +> Reports that do not include a video demonstrating the exploit will be closed without review. See [Reproduction Video Requirement](#reproduction-video-requirement) below. + We value the security community's role in protecting our systems and users. To report a security vulnerability: - File a private vulnerability report on GitHub: [Report a vulnerability](https://github.com/BerriAI/litellm/security/advisories/new) - Include steps to reproduce the issue +- Include a video or screen recording demonstrating the full exploit against a live LiteLLM instance, from initial access through to impact. A terminal recording (for example asciinema) is fine for CLI-only exploits. - Provide any relevant additional information +### Reproduction Video Requirement + +A video demonstrating the exploit is required for every report. AI tools have made it easy to produce plausible-sounding vulnerability reports that do not reproduce in practice, and triaging them takes time away from real issues. Reports submitted without a working reproduction video will be closed without review. If you add a video to a closed report, we will reopen and triage it. + ### Vulnerability Categories We classify vulnerabilities into the following categories: @@ -38,7 +46,7 @@ We offer bounties for responsibly disclosed vulnerabilities based on severity: | **Medium** | N/A | P2 authenticated privilege escalation | | **Low** | N/A | Minor information disclosure, low-impact misconfigurations | -To qualify for a bounty, reports must include clear reproduction steps and must not involve systems or accounts you do not own. We review all submissions promptly and will follow up within 5 business days. +To qualify for a bounty, reports must include clear reproduction steps, a reproduction video as described above, and must not involve systems or accounts you do not own. We review all submissions promptly and will follow up within 5 business days. ### Known Non-Issues diff --git a/tests/llm_translation/reasoning_effort_grid/grid_spec.py b/tests/llm_translation/reasoning_effort_grid/grid_spec.py index a08013cd439..83a2c286d64 100644 --- a/tests/llm_translation/reasoning_effort_grid/grid_spec.py +++ b/tests/llm_translation/reasoning_effort_grid/grid_spec.py @@ -1,7 +1,6 @@ from dataclasses import dataclass, field from typing import Dict, FrozenSet, List, Optional, Tuple - OMIT = object() @@ -136,6 +135,13 @@ def expected(model: ModelEntry, effort: str) -> CellExpectation: ANTHROPIC_DIRECT_MODELS: Tuple[ModelEntry, ...] = ( + ModelEntry( + alias="claude-fable-5", + model="anthropic/claude-fable-5", + mode="adaptive", + required_env=_ANTHROPIC_REQ, + caps=_CAPS_XHIGH_MAX, + ), ModelEntry( alias="claude-opus-4-8", model="anthropic/claude-opus-4-8", @@ -168,6 +174,19 @@ def expected(model: ModelEntry, effort: str) -> CellExpectation: AZURE_AI_MODELS: Tuple[ModelEntry, ...] = ( + ModelEntry( + alias="azure-claude-fable-5", + model="azure_ai/claude-fable-5", + mode="adaptive", + required_env=_AZURE_FOUNDRY_REQ, + caps=_CAPS_XHIGH_MAX, + fail_reason=( + "claude-fable-5 has no deployment on the CI Microsoft Foundry " + "resource yet; Foundry returns DeploymentNotFound until someone " + "creates the fable-5 deployment, so this cell stays loud in CI. " + "Remove this fail_reason once the deployment exists." + ), + ), ModelEntry( alias="azure-claude-opus-4-8", model="azure_ai/claude-opus-4-8", @@ -213,6 +232,20 @@ def expected(model: ModelEntry, effort: str) -> CellExpectation: VERTEX_AI_MODELS: Tuple[ModelEntry, ...] = ( + ModelEntry( + alias="vertex-claude-fable-5", + model="vertex_ai/claude-fable-5", + mode="adaptive", + extra_params=(("vertex_location", "global"),), + required_env=_VERTEX_REQ, + caps=_CAPS_XHIGH_MAX, + fail_reason=( + "claude-fable-5 availability on the CI Vertex project is not yet " + "confirmed for this brand-new release, so this cell stays loud in " + "CI until verified. Remove this fail_reason once the model is " + "confirmed available on the global Vertex endpoint." + ), + ), ModelEntry( alias="vertex-claude-opus-4-8", model="vertex_ai/claude-opus-4-8", @@ -263,6 +296,23 @@ def expected(model: ModelEntry, effort: str) -> CellExpectation: BEDROCK_CONVERSE_MODELS: Tuple[ModelEntry, ...] = ( + ModelEntry( + alias="bedrock-claude-fable-5", + model="bedrock/converse/us.anthropic.claude-fable-5", + mode="adaptive", + extra_params=(("aws_region_name", "us-east-1"),), + required_env=_BEDROCK_REQ, + caps=_CAPS_XHIGH_MAX, + bedrock_effort_ceiling="xhigh", + unavailable_error="is not available for this account", + fail_reason=( + "claude-fable-5 on Bedrock requires the account to opt in to " + "provider data sharing (data retention mode " + "'provider_data_sharing' via the Data Retention API); the CI " + "account has not opted in yet, so this cell stays loud in CI. " + "Remove this fail_reason once the opt-in is done." + ), + ), ModelEntry( alias="bedrock-claude-opus-4-8", model="bedrock/converse/us.anthropic.claude-opus-4-8", diff --git a/tests/llm_translation/reasoning_effort_grid/test_reasoning_effort_grid.py b/tests/llm_translation/reasoning_effort_grid/test_reasoning_effort_grid.py index 551ab8459d1..a5f16f928e5 100644 --- a/tests/llm_translation/reasoning_effort_grid/test_reasoning_effort_grid.py +++ b/tests/llm_translation/reasoning_effort_grid/test_reasoning_effort_grid.py @@ -15,7 +15,6 @@ all_cells, ) - _PROMPT_MESSAGES: List[Dict[str, str]] = [ {"role": "user", "content": "Step by step, calculate 47 * 53. Show your work."} ] @@ -201,8 +200,8 @@ async def test_reasoning_effort_grid( def test_grid_cell_count() -> None: - assert len(_PARAMS) == 25 * 11, ( - f"expected 275 cells (25 provider x model combos x 11 efforts), " + assert len(_PARAMS) == 29 * 11, ( + f"expected 319 cells (29 provider x model combos x 11 efforts), " f"got {len(_PARAMS)}" ) diff --git a/tests/proxy_behavior/management/test_team_budget_limits.py b/tests/proxy_behavior/management/test_team_budget_limits.py index 1534cee2b2e..dad775370ad 100644 --- a/tests/proxy_behavior/management/test_team_budget_limits.py +++ b/tests/proxy_behavior/management/test_team_budget_limits.py @@ -28,7 +28,7 @@ from litellm.proxy.utils import hash_token from .actors import Actor -from .conftest import create_scratch_org, create_scratch_team +from .conftest import MASTER_KEY, create_scratch_org, create_scratch_team pytestmark = pytest.mark.asyncio(loop_scope="session") @@ -288,34 +288,130 @@ async def test_check_user_team_limits( # --------------------------------------------------------------------------- -# /team/update path — _check_user_team_limits on existing team, no-org. -# Pin one over-budget rejection here so the update-side wiring is also -# covered (the update path is a second call site with its own data shape). +# /team/update path — budget authority. +# +# The caller's PERSONAL limits are never applied on update (that compared the +# wrong thing). But raising a team's spend ceiling is reserved for proxy admins: +# a team admin may keep or LOWER the budget, only a proxy admin may RAISE it. +# _check_user_team_limits() only runs on /team/new. # --------------------------------------------------------------------------- -async def test_team_update_user_limit_rejected(proxy_client, prisma, scratch): +async def test_team_admin_raise_budget_blocked(proxy_client, prisma, scratch): + """A team admin cannot raise the team's budget; the block is NOT based on + their personal budget (which here is higher than the requested value).""" caller_cleartext = await _seed_scratch_actor_with_caps( prisma, scratch.prefix, - max_budget=100.0, + max_budget=100000.0, # generous personal budget; must not matter ) creator_user_id = f"{scratch.prefix}-team-creator" - # Team must exist before /team/update; seed a standalone scratch team - # owned by the same actor so the update authz gate passes. team_id = await create_scratch_team( prisma, team_id=scratch.tag("team"), admin_user_ids=[creator_user_id], max_budget=50.0, ) + # Raise the team budget 50 -> 999 as a team admin. resp = await proxy_client.post( "/team/update", headers={"Authorization": f"Bearer {caller_cleartext}"}, json={"team_id": team_id, "max_budget": 999.0}, ) - assert resp.status_code == 400, resp.text + assert resp.status_code == 403, resp.text row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": team_id}) assert row is not None - assert row.max_budget == 50.0, "row max_budget mutated despite rejection" + assert row.max_budget == 50.0, "team budget must not change on a blocked raise" + + +async def test_team_admin_lower_budget_allowed(proxy_client, prisma, scratch): + """A team admin may freely lower (or keep) the team's budget.""" + caller_cleartext = await _seed_scratch_actor_with_caps( + prisma, + scratch.prefix, + max_budget=10.0, # below both the old and new team budget; must not matter + ) + creator_user_id = f"{scratch.prefix}-team-creator" + team_id = await create_scratch_team( + prisma, + team_id=scratch.tag("team"), + admin_user_ids=[creator_user_id], + max_budget=500.0, + ) + # Lower the team budget 500 -> 300 as a team admin. + resp = await proxy_client.post( + "/team/update", + headers={"Authorization": f"Bearer {caller_cleartext}"}, + json={"team_id": team_id, "max_budget": 300.0}, + ) + assert resp.status_code == 200, resp.text + + row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": team_id}) + assert row is not None + assert row.max_budget == 300.0, "team admin should be able to lower the budget" + + +async def test_proxy_admin_raise_budget_allowed(proxy_client, prisma, scratch): + """A proxy admin may raise a team's budget.""" + team_id = await create_scratch_team( + prisma, + team_id=scratch.tag("team"), + admin_user_ids=[f"{scratch.prefix}-team-creator"], + max_budget=50.0, + ) + # MASTER_KEY acts as proxy admin. + resp = await proxy_client.post( + "/team/update", + headers={"Authorization": f"Bearer {MASTER_KEY}"}, + json={"team_id": team_id, "max_budget": 999.0}, + ) + assert resp.status_code == 200, resp.text + + row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": team_id}) + assert row is not None + assert row.max_budget == 999.0, "proxy admin should be able to raise the budget" + + +async def test_team_admin_remove_budget_cap_blocked(proxy_client, prisma, scratch): + """A team admin cannot strip the team's cap (max_budget=null); removing the + ceiling is the strongest possible raise -> proxy-admin only.""" + caller_cleartext = await _seed_scratch_actor_with_caps( + prisma, scratch.prefix, max_budget=100000.0 + ) + team_id = await create_scratch_team( + prisma, + team_id=scratch.tag("team"), + admin_user_ids=[f"{scratch.prefix}-team-creator"], + max_budget=50.0, + ) + resp = await proxy_client.post( + "/team/update", + headers={"Authorization": f"Bearer {caller_cleartext}"}, + json={"team_id": team_id, "max_budget": None}, + ) + assert resp.status_code == 403, resp.text + + row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": team_id}) + assert row is not None + assert row.max_budget == 50.0, "team budget cap must not be removed by a team admin" + + +async def test_proxy_admin_remove_budget_cap_allowed(proxy_client, prisma, scratch): + """A proxy admin may remove a team's cap (max_budget=null).""" + team_id = await create_scratch_team( + prisma, + team_id=scratch.tag("team"), + admin_user_ids=[f"{scratch.prefix}-team-creator"], + max_budget=50.0, + ) + resp = await proxy_client.post( + "/team/update", + headers={"Authorization": f"Bearer {MASTER_KEY}"}, + json={"team_id": team_id, "max_budget": None}, + ) + assert resp.status_code == 200, resp.text + + row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": team_id}) + assert row is not None + assert row.max_budget is None, "proxy admin should be able to remove the cap" diff --git a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py index 75038574c63..abb162e9ddb 100644 --- a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py +++ b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py @@ -5261,6 +5261,8 @@ def test_should_strip_billing_metadata_by_provider( config_cls = getattr(importlib.import_module(module_path), class_name) assert config_cls().should_strip_billing_metadata() is expected_strip + + def test_namespace_tool_flat_nested_tools_are_extracted(): """Codex sends nested tools in flat format {type, name, description, parameters} with no 'function' wrapper. These must be normalized and mapped without raising KeyError: 'function'.""" @@ -5357,3 +5359,140 @@ def test_client_metadata_stripped_from_anthropic_request(): headers={}, ) assert "client_metadata" not in result + + +@pytest.mark.parametrize( + "model", + ["claude-fable-5", "claude-opus-4-7", "claude-opus-4-8-20260120"], +) +def test_sampling_params_dropped_for_models_that_removed_them(model): + """Fable 5 / Opus 4.7 / 4.8 reject temperature != 1 and any top_p with a + 400; with drop_params set they must be dropped, not forwarded (#30064).""" + config = AnthropicConfig() + + result = config.map_openai_params( + non_default_params={"temperature": 0.5, "top_p": 0.9}, + optional_params={}, + model=model, + drop_params=True, + ) + + assert "temperature" not in result + assert "top_p" not in result + + +@pytest.mark.parametrize("params", [{"temperature": 0.5}, {"top_p": 0.9}, {"top_p": 1}]) +def test_sampling_params_raise_clean_error_without_drop_params(params, monkeypatch): + monkeypatch.setattr(litellm, "drop_params", False) + config = AnthropicConfig() + + with pytest.raises(litellm.utils.UnsupportedParamsError, match="drop_params"): + config.map_openai_params( + non_default_params=params, + optional_params={}, + model="claude-fable-5", + drop_params=False, + ) + + +def test_temperature_1_forwarded_on_models_that_removed_sampling_params(): + """temperature=1 (the API default) is still accepted and must pass through.""" + config = AnthropicConfig() + + result = config.map_openai_params( + non_default_params={"temperature": 1}, + optional_params={}, + model="claude-fable-5", + drop_params=False, + ) + + assert result["temperature"] == 1 + + +@pytest.mark.parametrize("model", ["claude-opus-4-6", "claude-sonnet-4-6"]) +def test_sampling_params_forwarded_on_models_that_accept_them(model): + config = AnthropicConfig() + + result = config.map_openai_params( + non_default_params={"temperature": 0.5, "top_p": 0.9}, + optional_params={}, + model=model, + drop_params=True, + ) + + assert result["temperature"] == 0.5 + assert result["top_p"] == 0.9 + + +def test_sampling_param_gating_driven_by_model_map_flag(monkeypatch): + """The drop/raise decision must come from ``supports_sampling_params`` in + the model map, not just name matching: a flagged entry gates a model whose + name says nothing, and an explicit ``true`` overrides the name fallback.""" + monkeypatch.setitem( + litellm.model_cost, "claude-zeta-9", {"supports_sampling_params": False} + ) + monkeypatch.setitem( + litellm.model_cost, "claude-fable-5-test", {"supports_sampling_params": True} + ) + config = AnthropicConfig() + + flagged_off = config.map_openai_params( + non_default_params={"top_p": 0.9}, + optional_params={}, + model="claude-zeta-9", + drop_params=True, + ) + assert "top_p" not in flagged_off + + flagged_on = config.map_openai_params( + non_default_params={"top_p": 0.9}, + optional_params={}, + model="claude-fable-5-test", + drop_params=True, + ) + assert flagged_on["top_p"] == 0.9 + + +def test_top_k_dropped_at_transform_for_models_that_removed_it(): + """``top_k`` is a provider-specific kwarg that bypasses + ``map_openai_params``, so it must be stripped at the transform_request + boundary shared by the direct, invoke, Vertex, and Azure paths (#30064).""" + config = AnthropicConfig() + + result = config.transform_request( + model="claude-fable-5", + messages=[{"role": "user", "content": "hello"}], + optional_params={"max_tokens": 10, "top_k": 40}, + litellm_params={"drop_params": True}, + headers={}, + ) + + assert "top_k" not in result + + +def test_top_k_raises_at_transform_without_drop_params(monkeypatch): + monkeypatch.setattr(litellm, "drop_params", False) + config = AnthropicConfig() + + with pytest.raises(litellm.utils.UnsupportedParamsError, match="drop_params"): + config.transform_request( + model="claude-fable-5", + messages=[{"role": "user", "content": "hello"}], + optional_params={"max_tokens": 10, "top_k": 40}, + litellm_params={}, + headers={}, + ) + + +def test_top_k_forwarded_at_transform_on_models_that_accept_it(): + config = AnthropicConfig() + + result = config.transform_request( + model="claude-sonnet-4-6", + messages=[{"role": "user", "content": "hello"}], + optional_params={"max_tokens": 10, "top_k": 40}, + litellm_params={"drop_params": True}, + headers={}, + ) + + assert result["top_k"] == 40 diff --git a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py index ed978113b8b..5c83f8b34f7 100644 --- a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py @@ -5267,3 +5267,122 @@ def text(self): msg = str(exc_info.value) assert "secret content" not in msg assert "Error converting to valid response block" in msg + + +def test_converse_drops_sampling_params_for_models_that_removed_them(): + """Fable 5 / Opus 4.7 / 4.8 reject temperature != 1 and any top_p; with + drop_params set, converse must drop them instead of forwarding (#30064).""" + config = AmazonConverseConfig() + + result = config.map_openai_params( + non_default_params={"temperature": 0.5, "top_p": 0.9}, + optional_params={}, + model="us.anthropic.claude-fable-5", + drop_params=True, + ) + + assert "temperature" not in result + assert "topP" not in result + + +def test_converse_sampling_params_raise_without_drop_params(monkeypatch): + monkeypatch.setattr(litellm, "drop_params", False) + config = AmazonConverseConfig() + + with pytest.raises(litellm.utils.UnsupportedParamsError, match="drop_params"): + config.map_openai_params( + non_default_params={"temperature": 0.5}, + optional_params={}, + model="global.anthropic.claude-opus-4-8-v1:0", + drop_params=False, + ) + + +def test_converse_sampling_params_forwarded_on_models_that_accept_them(): + config = AmazonConverseConfig() + + result = config.map_openai_params( + non_default_params={"temperature": 0.5, "top_p": 0.9}, + optional_params={}, + model="us.anthropic.claude-sonnet-4-6", + drop_params=True, + ) + + assert result["temperature"] == 0.5 + assert result["topP"] == 0.9 + + +def test_converse_top_k_dropped_for_models_that_removed_it(): + """``top_k`` reaches converse as a provider-specific kwarg destined for + ``additionalModelRequestFields``, bypassing ``map_openai_params``; the + transform must strip it for models that removed sampling params (#30064).""" + config = AmazonConverseConfig() + + result = config.transform_request( + model="us.anthropic.claude-fable-5", + messages=[{"role": "user", "content": "hello"}], + optional_params={"top_k": 40}, + litellm_params={"drop_params": True}, + headers={}, + ) + + assert "top_k" not in result.get("additionalModelRequestFields", {}) + + +def test_converse_top_k_raises_without_drop_params(monkeypatch): + monkeypatch.setattr(litellm, "drop_params", False) + config = AmazonConverseConfig() + + with pytest.raises(litellm.utils.UnsupportedParamsError, match="drop_params"): + config.transform_request( + model="us.anthropic.claude-fable-5", + messages=[{"role": "user", "content": "hello"}], + optional_params={"top_k": 40}, + litellm_params={}, + headers={}, + ) + + +def test_converse_top_k_forwarded_on_models_that_accept_it(): + config = AmazonConverseConfig() + + result = config.transform_request( + model="us.anthropic.claude-sonnet-4-6", + messages=[{"role": "user", "content": "hello"}], + optional_params={"top_k": 40}, + litellm_params={"drop_params": True}, + headers={}, + ) + + assert result["additionalModelRequestFields"]["top_k"] == 40 + + +def test_converse_top_k_zero_raises_without_drop_params(monkeypatch): + """``top_k=0`` must hit the same gating as any other value; previously the + truthiness check let it silently disappear on models that removed sampling + params, diverging from the Anthropic boundary that treats ``0`` as present.""" + monkeypatch.setattr(litellm, "drop_params", False) + config = AmazonConverseConfig() + + with pytest.raises(litellm.utils.UnsupportedParamsError, match="drop_params"): + config.transform_request( + model="us.anthropic.claude-fable-5", + messages=[{"role": "user", "content": "hello"}], + optional_params={"top_k": 0}, + litellm_params={}, + headers={}, + ) + + +def test_converse_top_k_zero_forwarded_on_models_that_accept_it(): + config = AmazonConverseConfig() + + result = config.transform_request( + model="us.anthropic.claude-sonnet-4-6", + messages=[{"role": "user", "content": "hello"}], + optional_params={"top_k": 0}, + litellm_params={"drop_params": True}, + headers={}, + ) + + assert result["additionalModelRequestFields"]["top_k"] == 0 diff --git a/tests/test_litellm/proxy/hooks/test_batch_file_validation.py b/tests/test_litellm/proxy/hooks/test_batch_file_validation.py index ae71d10b378..a6f6e651487 100644 --- a/tests/test_litellm/proxy/hooks/test_batch_file_validation.py +++ b/tests/test_litellm/proxy/hooks/test_batch_file_validation.py @@ -713,7 +713,8 @@ async def test_count_input_file_usage_decodes_model_embedded_file_id(): @pytest.mark.asyncio async def test_pre_call_allows_stripped_provider_model_when_key_has_proxy_alias(): """After replace_model_in_jsonl, body.model is the provider id (e.g. gpt-5.5). - Auth must check the proxy model_name the key was granted, not the stripped id.""" + Auth must check target_model_names from the unified file id, not reverse-map + the stripped id.""" from litellm.proxy.hooks.batch_rate_limiter import _PROXY_BatchRateLimiter rate_limiter = _PROXY_BatchRateLimiter( @@ -732,7 +733,6 @@ async def test_pre_call_allows_stripped_provider_model_when_key_has_proxy_alias( ) mock_router = MagicMock() mock_router.model_list = [] - mock_router.resolve_model_name_from_model_id.return_value = proxy_alias can_key_call_model = AsyncMock(return_value=True) with ( @@ -745,10 +745,105 @@ async def test_pre_call_allows_stripped_provider_model_when_key_has_proxy_alias( await rate_limiter._enforce_batch_file_model_access( user_api_key_dict=user, file_content_as_dict=file_dict, + target_model_names=[proxy_alias], ) can_key_call_model.assert_awaited_once() assert can_key_call_model.await_args.kwargs["model"] == proxy_alias + mock_router.resolve_model_name_from_model_id.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "model_list_order", + [ + [ + "openai/openai/gpt-5.5", + "openai/openai/gpt-5.5-batch", + "us/azure/openai/gpt-5.5", + ], + [ + "us/azure/openai/gpt-5.5", + "openai/openai/gpt-5.5", + "openai/openai/gpt-5.5-batch", + ], + [ + "openai/openai/gpt-5.5-batch", + "us/azure/openai/gpt-5.5", + "openai/openai/gpt-5.5", + ], + ], +) +async def test_pre_call_uses_target_model_names_not_stripped_reverse_lookup( + model_list_order, +): + """LIT-3593: three deployments strip to gpt-5.5; auth must use the upload + target alias from target_model_names, not first-match reverse lookup.""" + from litellm.proxy.hooks.batch_rate_limiter import _PROXY_BatchRateLimiter + + rate_limiter = _PROXY_BatchRateLimiter( + internal_usage_cache=MagicMock(), + parallel_request_limiter=MagicMock(), + ) + batch_alias = "openai/openai/gpt-5.5-batch" + deployment_templates = { + "openai/openai/gpt-5.5": { + "model_name": "openai/openai/gpt-5.5", + "litellm_params": {"model": "openai/gpt-5.5"}, + "model_info": {"id": "openai/openai/gpt-5.5", "mode": "chat"}, + }, + "openai/openai/gpt-5.5-batch": { + "model_name": "openai/openai/gpt-5.5-batch", + "litellm_params": {"model": "openai/gpt-5.5"}, + "model_info": {"id": "openai/openai/gpt-5.5-batch", "mode": "batch"}, + }, + "us/azure/openai/gpt-5.5": { + "model_name": "us/azure/openai/gpt-5.5", + "litellm_params": {"model": "azure/gpt-5.5"}, + "model_info": {"id": "openai/openai/gpt-5.5", "mode": "chat"}, + }, + } + mock_router = MagicMock() + mock_router.model_list = [deployment_templates[name] for name in model_list_order] + + def _resolve(model_id): + for deployment in mock_router.model_list: + actual_model = deployment.get("litellm_params", {}).get("model") + if actual_model == model_id or ( + actual_model and actual_model.endswith(f"/{model_id}") + ): + return deployment.get("model_name") + return None + + mock_router.resolve_model_name_from_model_id.side_effect = _resolve + + file_dict = [ + {"body": {"model": "gpt-5.5", "messages": [{"role": "user", "content": "x"}]}} + ] + user = UserAPIKeyAuth( + api_key="sk-ok", + user_id="alice", + models=[batch_alias], + user_role=LitellmUserRoles.INTERNAL_USER.value, + ) + can_key_call_model = AsyncMock(return_value=True) + + with ( + patch( + "litellm.proxy.auth.auth_checks.can_key_call_model", + new=can_key_call_model, + ), + patch("litellm.proxy.proxy_server.llm_router", mock_router), + ): + await rate_limiter._enforce_batch_file_model_access( + user_api_key_dict=user, + file_content_as_dict=file_dict, + target_model_names=[batch_alias], + ) + + can_key_call_model.assert_awaited_once() + assert can_key_call_model.await_args.kwargs["model"] == batch_alias + mock_router.resolve_model_name_from_model_id.assert_not_called() @pytest.mark.asyncio diff --git a/tests/test_litellm/proxy/hooks/test_post_call_response_headers_hook.py b/tests/test_litellm/proxy/hooks/test_post_call_response_headers_hook.py index 8d8dd2d4284..660b0b0162a 100644 --- a/tests/test_litellm/proxy/hooks/test_post_call_response_headers_hook.py +++ b/tests/test_litellm/proxy/hooks/test_post_call_response_headers_hook.py @@ -13,7 +13,6 @@ sys.path.insert(0, os.path.abspath("../../../..")) -import litellm from litellm.integrations.custom_logger import CustomLogger from litellm.proxy._types import UserAPIKeyAuth @@ -336,3 +335,110 @@ class MockResponse: assert result == {"x-test": "1"} assert injector.called is True + + +# --- Tests for custom_llm_provider fallback (streaming response types) --- + + +@pytest.mark.asyncio +async def test_litellm_call_info_fallback_to_response_attribute(): + """Test that _build_litellm_call_info falls back to response.custom_llm_provider + when _hidden_params doesn't contain it (streaming response types).""" + inspector = CallInfoInspectorLogger() + + class MockStreamResponse: + """Mimics CustomStreamWrapper: custom_llm_provider as attribute, + _hidden_params without it.""" + + custom_llm_provider = "bedrock" + _hidden_params = { + "model_id": "model-xyz", + "api_base": "https://bedrock.us-east-1.amazonaws.com", + } + + with patch("litellm.callbacks", [inspector]): + from litellm.proxy.utils import ProxyLogging + from litellm.caching.caching import DualCache + + proxy_logging = ProxyLogging(user_api_key_cache=DualCache()) + + await proxy_logging.post_call_response_headers_hook( + data={ + "model": "claude-3", + "metadata": {"model_info": {"id": "model-xyz"}}, + }, + user_api_key_dict=UserAPIKeyAuth(api_key="test-key"), + response=MockStreamResponse(), + ) + + assert inspector.called is True + assert inspector.received_call_info is not None + assert inspector.received_call_info["custom_llm_provider"] == "bedrock" + assert ( + inspector.received_call_info["api_base"] + == "https://bedrock.us-east-1.amazonaws.com" + ) + assert inspector.received_call_info["model_id"] == "model-xyz" + + +@pytest.mark.asyncio +async def test_litellm_call_info_fallback_no_hidden_params(): + """Test that _build_litellm_call_info works when response has no _hidden_params + at all (LiteLLMCompletionStreamingIterator case).""" + inspector = CallInfoInspectorLogger() + + class MockIteratorResponse: + """Mimics LiteLLMCompletionStreamingIterator: custom_llm_provider as attribute, + no _hidden_params attribute at all.""" + + custom_llm_provider = "vertex_ai" + + with patch("litellm.callbacks", [inspector]): + from litellm.proxy.utils import ProxyLogging + from litellm.caching.caching import DualCache + + proxy_logging = ProxyLogging(user_api_key_cache=DualCache()) + + await proxy_logging.post_call_response_headers_hook( + data={"model": "gemini-pro", "metadata": {}}, + user_api_key_dict=UserAPIKeyAuth(api_key="test-key"), + response=MockIteratorResponse(), + ) + + assert inspector.called is True + assert inspector.received_call_info is not None + assert inspector.received_call_info["custom_llm_provider"] == "vertex_ai" + assert inspector.received_call_info["api_base"] is None + assert inspector.received_call_info["model_id"] is None + + +@pytest.mark.asyncio +async def test_litellm_call_info_hidden_params_takes_priority(): + """Test that _hidden_params.custom_llm_provider takes priority over + the response attribute when both are present.""" + inspector = CallInfoInspectorLogger() + + class MockResponse: + custom_llm_provider = "attribute_value" + _hidden_params = { + "custom_llm_provider": "hidden_params_value", + "api_base": "https://example.com", + "model_id": "m1", + } + + with patch("litellm.callbacks", [inspector]): + from litellm.proxy.utils import ProxyLogging + from litellm.caching.caching import DualCache + + proxy_logging = ProxyLogging(user_api_key_cache=DualCache()) + + await proxy_logging.post_call_response_headers_hook( + data={"model": "test", "metadata": {}}, + user_api_key_dict=UserAPIKeyAuth(api_key="test-key"), + response=MockResponse(), + ) + + assert ( + inspector.received_call_info["custom_llm_provider"] + == "hidden_params_value" + ) diff --git a/tests/test_litellm/proxy/image_endpoints/test_endpoints.py b/tests/test_litellm/proxy/image_endpoints/test_endpoints.py index 8fec05abe90..91a011a8234 100644 --- a/tests/test_litellm/proxy/image_endpoints/test_endpoints.py +++ b/tests/test_litellm/proxy/image_endpoints/test_endpoints.py @@ -43,11 +43,15 @@ async def fake_post_call_failure_hook(**_: Any) -> None: async def fake_post_call_success_hook(*, data, user_api_key_dict, response): return response + async def fake_post_call_response_headers_hook(**kwargs): + return {"x-callback-test": "value"} + fake_proxy_logger = SimpleNamespace( pre_call_hook=fake_pre_call_hook, update_request_status=fake_update_request_status, post_call_failure_hook=fake_post_call_failure_hook, post_call_success_hook=fake_post_call_success_hook, + post_call_response_headers_hook=fake_post_call_response_headers_hook, ) captured_route_request_data: Dict[str, Any] = {} @@ -110,3 +114,4 @@ async def receive(): assert pre_call_input["messages"][0]["content"] == "original prompt" assert captured_route_request_data["prompt"] == "sanitized prompt" assert "messages" not in captured_route_request_data + assert response.headers.get("x-callback-test") == "value" diff --git a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py index 947b39e8367..0b5b5fb6ceb 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py @@ -1482,6 +1482,10 @@ async def test_get_cached_temporary_mcp_server_non_admin_denied(self): "litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager", mock_manager, ), + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.build_effective_auth_contexts", + AsyncMock(return_value=[non_admin]), + ), ): with pytest.raises(HTTPException) as exc_info: await _get_cached_temporary_mcp_server_or_404("server-x", non_admin) @@ -1514,6 +1518,10 @@ async def test_get_cached_temporary_mcp_server_non_admin_allowed(self): "litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager", mock_manager, ), + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.build_effective_auth_contexts", + AsyncMock(return_value=[non_admin]), + ), ): result = await _get_cached_temporary_mcp_server_or_404( "server-x", non_admin @@ -1521,6 +1529,58 @@ async def test_get_cached_temporary_mcp_server_non_admin_allowed(self): assert result is registry_server + @pytest.mark.asyncio + async def test_get_cached_temporary_mcp_server_non_admin_allowed_via_team_access_group( + self, + ): + """Internal user whose only grant to the server flows through a team + access-group must pass the authorize/token access check. The check has to + expand the UI session into per-team contexts (build_effective_auth_contexts), + the same way the server-list grid does; checking only the bare session + context leaves the team grant invisible and 403s the user.""" + from litellm.constants import UI_SESSION_TOKEN_TEAM_ID + from litellm.proxy.management_endpoints.mcp_management_endpoints import ( + _get_cached_temporary_mcp_server_or_404, + ) + + registry_server = generate_mock_mcp_server_config_record(server_id="server-x") + ui_session_auth = generate_mock_user_api_key_auth( + user_role=LitellmUserRoles.INTERNAL_USER, + team_id=UI_SESSION_TOKEN_TEAM_ID, + ) + team_context = ui_session_auth.model_copy() + team_context.team_id = "team-with-mcp-grant" + + mock_manager = MagicMock() + mock_manager.get_mcp_server_by_id.return_value = registry_server + mock_manager.get_mcp_server_by_name.return_value = None + + def allowed_for(auth): + return ["server-x"] if auth.team_id == "team-with-mcp-grant" else [] + + mock_manager.get_allowed_mcp_servers = AsyncMock(side_effect=allowed_for) + + with ( + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.get_cached_temporary_mcp_server", + return_value=None, + ), + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager", + mock_manager, + ), + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.build_effective_auth_contexts", + AsyncMock(return_value=[ui_session_auth, team_context]), + ), + ): + result = await _get_cached_temporary_mcp_server_or_404( + "server-x", ui_session_auth + ) + + assert result is registry_server + assert mock_manager.get_allowed_mcp_servers.await_count == 2 + @pytest.mark.asyncio async def test_get_cached_temporary_mcp_server_temp_cache_non_admin_denied(self): """Servers resolved from the admin-only temp cache reject non-admins.""" diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py index 06adcf80707..b750b6d022c 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -4451,37 +4451,38 @@ async def test_new_team_org_scoped_models_not_in_org_models(): @pytest.mark.asyncio -async def test_update_team_standalone_budget_exceeds_user_limit(): +async def test_update_team_standalone_budget_raise_blocked_for_team_admin(): """ - Test that /team/update for a standalone team fails when new budget exceeds user's max_budget. + Test that /team/update for a standalone team blocks a non-proxy-admin + (team admin) from RAISING the team budget above the team's current value. + + Raising a team's spend ceiling is a budget-authority action reserved for + proxy admins. The rejection is NOT based on the caller's personal budget. Scenario: - - User has personal max_budget=$50 - - Standalone team exists (no organization_id) - - User tries to update team budget to $100 - - Expected: Should fail with error about exceeding user budget + - Team admin (internal_user) manages the team + - Standalone team exists with current budget=$30 + - Admin tries to raise team budget to $100 + - Expected: 403 (only a proxy admin may raise the team budget) """ from fastapi import Request from litellm.proxy._types import ( - LiteLLM_UserTable, ProxyException, UpdateTeamRequest, UserAPIKeyAuth, ) from litellm.proxy.management_endpoints.team_endpoints import update_team - # Create non-admin user with restrictive personal budget - non_admin_user = UserAPIKeyAuth( + team_admin_user = UserAPIKeyAuth( user_role=LitellmUserRoles.INTERNAL_USER, user_id="non-admin-update-test", models=[], ) - # Create update request with budget exceeding user's limit update_request = UpdateTeamRequest( team_id="standalone-team-123", - max_budget=100.0, # Exceeds user's $50 limit + max_budget=100.0, # Raise above the team's current $30 ) dummy_request = MagicMock(spec=Request) @@ -4492,13 +4493,13 @@ async def test_update_team_standalone_budget_exceeds_user_limit(): patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), patch( "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() - ) as mock_audit, + ), ): - # Mock existing standalone team (no organization_id) mock_existing_team = MagicMock() mock_existing_team.team_id = "standalone-team-123" mock_existing_team.organization_id = None # Standalone team mock_existing_team.max_budget = 30.0 + mock_existing_team.model_id = None mock_existing_team.model_dump.return_value = { "team_id": "standalone-team-123", "organization_id": None, @@ -4510,25 +4511,253 @@ async def test_update_team_standalone_budget_exceeds_user_limit(): mock_prisma.db.litellm_teamtable.find_unique = AsyncMock( return_value=mock_existing_team ) + mock_cache.async_get_cache = AsyncMock(return_value=None) - # Mock user cache to return user with restrictive budget - mock_user_obj = LiteLLM_UserTable( - user_id="non-admin-update-test", - max_budget=50.0, # User's budget limit + with pytest.raises(ProxyException) as exc_info: + await update_team( + data=update_request, + http_request=dummy_request, + user_api_key_dict=team_admin_user, + ) + + assert exc_info.value.code == "403" + assert "proxy admin" in str(exc_info.value.message).lower() + + +@pytest.mark.asyncio +async def test_update_team_standalone_budget_raise_allowed_for_proxy_admin(): + """ + Test that a proxy admin CAN raise a standalone team's budget on /team/update. + + Scenario: + - Caller is a proxy admin + - Standalone team exists with current budget=$30 + - Proxy admin raises team budget to $100 + - Expected: Should succeed (proxy admin holds budget authority) + """ + from fastapi import Request + + from litellm.proxy._types import UpdateTeamRequest, UserAPIKeyAuth + from litellm.proxy.management_endpoints.team_endpoints import update_team + + proxy_admin = UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, + user_id="proxy-admin-update-test", + models=[], + ) + + update_request = UpdateTeamRequest( + team_id="standalone-team-123", + max_budget=100.0, # Raise above the team's current $30 + ) + + dummy_request = MagicMock(spec=Request) + + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache, + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), + patch( + "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() + ), + ): + mock_existing_team = MagicMock() + mock_existing_team.team_id = "standalone-team-123" + mock_existing_team.organization_id = None + mock_existing_team.max_budget = 30.0 + mock_existing_team.model_id = None + mock_existing_team.model_dump.return_value = { + "team_id": "standalone-team-123", + "organization_id": None, + "max_budget": 30.0, + "members_with_roles": [ + {"user_id": "proxy-admin-update-test", "role": "admin"} + ], + } + mock_prisma.db.litellm_teamtable.find_unique = AsyncMock( + return_value=mock_existing_team + ) + mock_prisma.jsonify_team_object = lambda db_data: db_data + mock_cache.async_get_cache = AsyncMock(return_value=None) + mock_cache.async_set_cache = AsyncMock() + + mock_updated_team = MagicMock() + mock_updated_team.team_id = "standalone-team-123" + mock_updated_team.organization_id = None + mock_updated_team.max_budget = 100.0 + mock_updated_team.litellm_model_table = None + mock_updated_team.model_dump.return_value = { + "team_id": "standalone-team-123", + "organization_id": None, + "max_budget": 100.0, + } + mock_prisma.db.litellm_teamtable.update = AsyncMock( + return_value=mock_updated_team ) - mock_cache.async_get_cache = AsyncMock(return_value=mock_user_obj) - # Should raise ProxyException because new budget exceeds user's max_budget + result = await update_team( + data=update_request, + http_request=dummy_request, + user_api_key_dict=proxy_admin, + ) + + assert result is not None + assert result["data"].max_budget == 100.0 + + +@pytest.mark.asyncio +async def test_update_team_standalone_budget_removal_blocked_for_team_admin(): + """ + A team admin must not be able to REMOVE a team's spend ceiling + (max_budget=null), which is the strongest possible raise (finite -> unlimited). + + Scenario: + - Team admin (internal_user) manages a team with current budget=$500 + - Admin explicitly sets max_budget=None to strip the cap + - Expected: 403 (only a proxy admin can remove the team budget) + """ + from fastapi import Request + + from litellm.proxy._types import ( + ProxyException, + UpdateTeamRequest, + UserAPIKeyAuth, + ) + from litellm.proxy.management_endpoints.team_endpoints import update_team + + team_admin_user = UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + user_id="budget-removal-admin", + models=[], + ) + + # Explicitly set max_budget=None so it lands in model_fields_set and would be + # persisted by data.json(exclude_unset=True). + update_request = UpdateTeamRequest( + team_id="standalone-team-123", + max_budget=None, + ) + assert "max_budget" in update_request.model_fields_set + + dummy_request = MagicMock(spec=Request) + + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache, + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), + patch( + "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() + ), + ): + mock_existing_team = MagicMock() + mock_existing_team.team_id = "standalone-team-123" + mock_existing_team.organization_id = None + mock_existing_team.max_budget = 500.0 + mock_existing_team.model_id = None + mock_existing_team.model_dump.return_value = { + "team_id": "standalone-team-123", + "organization_id": None, + "max_budget": 500.0, + "members_with_roles": [ + {"user_id": "budget-removal-admin", "role": "admin"} + ], + } + mock_prisma.db.litellm_teamtable.find_unique = AsyncMock( + return_value=mock_existing_team + ) + mock_cache.async_get_cache = AsyncMock(return_value=None) + with pytest.raises(ProxyException) as exc_info: await update_team( data=update_request, http_request=dummy_request, - user_api_key_dict=non_admin_user, + user_api_key_dict=team_admin_user, ) - # Verify exception details - assert exc_info.value.code == "400" - assert "budget" in str(exc_info.value.message).lower() + assert exc_info.value.code == "403" + assert "remove" in str(exc_info.value.message).lower() + + +@pytest.mark.asyncio +async def test_update_team_standalone_uncapped_team_admin_sets_finite_allowed(): + """ + When a team currently has NO cap (max_budget=None / unlimited), a team admin + setting a finite max_budget is a RESTRICTION, not a raise, and is + intentionally allowed. + + Scenario: + - Team admin manages a team with current max_budget=None (unlimited) + - Admin sets max_budget=1000 (unlimited -> finite is more restrictive) + - Expected: 200 + """ + from fastapi import Request + + from litellm.proxy._types import UpdateTeamRequest, UserAPIKeyAuth + from litellm.proxy.management_endpoints.team_endpoints import update_team + + team_admin_user = UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + user_id="uncapped-team-admin", + models=[], + ) + + update_request = UpdateTeamRequest( + team_id="standalone-uncapped-123", + max_budget=1000.0, + ) + + dummy_request = MagicMock(spec=Request) + + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache, + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), + patch( + "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() + ), + ): + mock_existing_team = MagicMock() + mock_existing_team.team_id = "standalone-uncapped-123" + mock_existing_team.organization_id = None + mock_existing_team.max_budget = None # team has no cap + mock_existing_team.model_id = None + mock_existing_team.model_dump.return_value = { + "team_id": "standalone-uncapped-123", + "organization_id": None, + "max_budget": None, + "members_with_roles": [ + {"user_id": "uncapped-team-admin", "role": "admin"} + ], + } + mock_prisma.db.litellm_teamtable.find_unique = AsyncMock( + return_value=mock_existing_team + ) + mock_prisma.jsonify_team_object = lambda db_data: db_data + mock_cache.async_get_cache = AsyncMock(return_value=None) + mock_cache.async_set_cache = AsyncMock() + + mock_updated_team = MagicMock() + mock_updated_team.team_id = "standalone-uncapped-123" + mock_updated_team.organization_id = None + mock_updated_team.max_budget = 1000.0 + mock_updated_team.litellm_model_table = None + mock_updated_team.model_dump.return_value = { + "team_id": "standalone-uncapped-123", + "organization_id": None, + "max_budget": 1000.0, + } + mock_prisma.db.litellm_teamtable.update = AsyncMock( + return_value=mock_updated_team + ) + + result = await update_team( + data=update_request, + http_request=dummy_request, + user_api_key_dict=team_admin_user, + ) + + assert result is not None + assert result["data"].max_budget == 1000.0 @pytest.mark.asyncio @@ -4816,32 +5045,34 @@ async def test_update_team_org_scoped_budget_exceeds_org_limit(): @pytest.mark.asyncio -async def test_update_team_standalone_models_exceeds_user_limit(): +async def test_update_team_standalone_models_not_gated_by_user_limit(): """ - Test that /team/update for a standalone team fails when models are not in user's allowed models. + Test that /team/update for a standalone team does NOT gate the team's models + by the caller's personal allowed models. + + A team admin authorized via _verify_team_access() may set the team's models + independently of their own personal model list on update. Scenario: - - User has personal models=['gpt-3.5-turbo'] + - Team admin has personal models=['gpt-3.5-turbo'] - Standalone team exists (no organization_id) - - User tries to update team models to ['gpt-4'] (not in user's allowed models) - - Expected: Should fail with error about model not in user's allowed models + - Admin updates team models to ['gpt-4'] (not in their personal list) + - Expected: Should succeed (personal models are irrelevant on /team/update) """ from fastapi import Request - from litellm.proxy._types import ProxyException, UpdateTeamRequest, UserAPIKeyAuth + from litellm.proxy._types import UpdateTeamRequest, UserAPIKeyAuth from litellm.proxy.management_endpoints.team_endpoints import update_team - # Create non-admin user with restrictive personal models - non_admin_user = UserAPIKeyAuth( + team_admin_user = UserAPIKeyAuth( user_role=LitellmUserRoles.INTERNAL_USER, user_id="non-admin-update-models-test", - models=["gpt-3.5-turbo"], # Restrictive model list + models=["gpt-3.5-turbo"], # Restrictive personal model list ) - # Create update request with model not in user's allowed list update_request = UpdateTeamRequest( team_id="standalone-team-models-123", - models=["gpt-4"], # Not in user's allowed models + models=["gpt-4"], # Not in the admin's personal allowed models ) dummy_request = MagicMock(spec=Request) @@ -4859,6 +5090,7 @@ async def test_update_team_standalone_models_exceeds_user_limit(): mock_existing_team.team_id = "standalone-team-models-123" mock_existing_team.organization_id = None # Standalone team mock_existing_team.models = ["gpt-3.5-turbo"] + mock_existing_team.model_id = None mock_existing_team.model_dump.return_value = { "team_id": "standalone-team-models-123", "organization_id": None, @@ -4870,18 +5102,30 @@ async def test_update_team_standalone_models_exceeds_user_limit(): mock_prisma.db.litellm_teamtable.find_unique = AsyncMock( return_value=mock_existing_team ) + mock_prisma.jsonify_team_object = lambda db_data: db_data + mock_cache.async_get_cache = AsyncMock(return_value=None) + mock_cache.async_set_cache = AsyncMock() - # Should raise ProxyException because model not in user's allowed models - with pytest.raises(ProxyException) as exc_info: - await update_team( - data=update_request, - http_request=dummy_request, - user_api_key_dict=non_admin_user, - ) + mock_updated_team = MagicMock() + mock_updated_team.team_id = "standalone-team-models-123" + mock_updated_team.organization_id = None + mock_updated_team.litellm_model_table = None + mock_updated_team.model_dump.return_value = { + "team_id": "standalone-team-models-123", + "organization_id": None, + "models": ["gpt-4"], + } + mock_prisma.db.litellm_teamtable.update = AsyncMock( + return_value=mock_updated_team + ) - # Verify exception details - assert exc_info.value.code == "400" - assert "model" in str(exc_info.value.message).lower() + result = await update_team( + data=update_request, + http_request=dummy_request, + user_api_key_dict=team_admin_user, + ) + + assert result is not None @pytest.mark.asyncio @@ -5306,32 +5550,35 @@ async def test_update_team_org_scoped_models_with_all_proxy_models(): @pytest.mark.asyncio -async def test_update_team_tpm_limit_exceeds_user_limit(): +async def test_update_team_tpm_limit_not_gated_by_user_limit(): """ - Test that /team/update fails when TPM limit exceeds user's TPM limit. + Test that /team/update does NOT gate the team's tpm_limit by the caller's + personal tpm_limit. + + A team admin authorized via _verify_team_access() may raise the team's + tpm_limit above their own personal tpm_limit on update. Scenario: - - User has tpm_limit=1000 - - User tries to update team with tpm_limit=5000 - - Expected: Should fail with error about exceeding user TPM limit + - Team admin has personal tpm_limit=1000 + - Standalone team exists with tpm_limit=500 + - Admin updates team tpm_limit to 5000 (above their personal 1000) + - Expected: Should succeed (personal tpm is irrelevant on /team/update) """ from fastapi import Request - from litellm.proxy._types import ProxyException, UpdateTeamRequest, UserAPIKeyAuth + from litellm.proxy._types import UpdateTeamRequest, UserAPIKeyAuth from litellm.proxy.management_endpoints.team_endpoints import update_team - # Create non-admin user with TPM limit - non_admin_user = UserAPIKeyAuth( + team_admin_user = UserAPIKeyAuth( user_role=LitellmUserRoles.INTERNAL_USER, user_id="tpm-limit-user", models=[], - tpm_limit=1000, # User's TPM limit + tpm_limit=1000, # Restrictive personal TPM limit ) - # Create update request with TPM exceeding user's limit update_request = UpdateTeamRequest( team_id="team-tpm-test-123", - tpm_limit=5000, # Exceeds user's 1000 limit + tpm_limit=5000, # Above the admin's personal 1000 ) dummy_request = MagicMock(spec=Request) @@ -5340,12 +5587,16 @@ async def test_update_team_tpm_limit_exceeds_user_limit(): patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache, patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), + patch( + "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() + ), ): # Mock existing standalone team mock_existing_team = MagicMock() mock_existing_team.team_id = "team-tpm-test-123" mock_existing_team.organization_id = None mock_existing_team.tpm_limit = 500 + mock_existing_team.model_id = None mock_existing_team.model_dump.return_value = { "team_id": "team-tpm-test-123", "organization_id": None, @@ -5355,47 +5606,59 @@ async def test_update_team_tpm_limit_exceeds_user_limit(): mock_prisma.db.litellm_teamtable.find_unique = AsyncMock( return_value=mock_existing_team ) + mock_prisma.jsonify_team_object = lambda db_data: db_data + mock_cache.async_get_cache = AsyncMock(return_value=None) + mock_cache.async_set_cache = AsyncMock() - # Should raise ProxyException because new TPM exceeds user's limit - with pytest.raises(ProxyException) as exc_info: - await update_team( - data=update_request, - http_request=dummy_request, - user_api_key_dict=non_admin_user, - ) + mock_updated_team = MagicMock() + mock_updated_team.team_id = "team-tpm-test-123" + mock_updated_team.organization_id = None + mock_updated_team.litellm_model_table = None + mock_updated_team.model_dump.return_value = { + "team_id": "team-tpm-test-123", + "organization_id": None, + "tpm_limit": 5000, + } + mock_prisma.db.litellm_teamtable.update = AsyncMock( + return_value=mock_updated_team + ) - # Verify exception details - assert exc_info.value.code == "400" - assert "tpm" in str(exc_info.value.message).lower() + result = await update_team( + data=update_request, + http_request=dummy_request, + user_api_key_dict=team_admin_user, + ) + + assert result is not None @pytest.mark.asyncio -async def test_update_team_rpm_limit_exceeds_user_limit(): +async def test_update_team_rpm_limit_not_gated_by_user_limit(): """ - Test that /team/update fails when RPM limit exceeds user's RPM limit. + Test that /team/update does NOT gate the team's rpm_limit by the caller's + personal rpm_limit. Scenario: - - User has rpm_limit=100 - - User tries to update team with rpm_limit=500 - - Expected: Should fail with error about exceeding user RPM limit + - Team admin has personal rpm_limit=100 + - Standalone team exists with rpm_limit=50 + - Admin updates team rpm_limit to 500 (above their personal 100) + - Expected: Should succeed (personal rpm is irrelevant on /team/update) """ from fastapi import Request - from litellm.proxy._types import ProxyException, UpdateTeamRequest, UserAPIKeyAuth + from litellm.proxy._types import UpdateTeamRequest, UserAPIKeyAuth from litellm.proxy.management_endpoints.team_endpoints import update_team - # Create non-admin user with RPM limit - non_admin_user = UserAPIKeyAuth( + team_admin_user = UserAPIKeyAuth( user_role=LitellmUserRoles.INTERNAL_USER, user_id="rpm-limit-user", models=[], - rpm_limit=100, # User's RPM limit + rpm_limit=100, # Restrictive personal RPM limit ) - # Create update request with RPM exceeding user's limit update_request = UpdateTeamRequest( team_id="team-rpm-test-123", - rpm_limit=500, # Exceeds user's 100 limit + rpm_limit=500, # Above the admin's personal 100 ) dummy_request = MagicMock(spec=Request) @@ -5404,12 +5667,16 @@ async def test_update_team_rpm_limit_exceeds_user_limit(): patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache, patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), + patch( + "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() + ), ): # Mock existing standalone team mock_existing_team = MagicMock() mock_existing_team.team_id = "team-rpm-test-123" mock_existing_team.organization_id = None mock_existing_team.rpm_limit = 50 + mock_existing_team.model_id = None mock_existing_team.model_dump.return_value = { "team_id": "team-rpm-test-123", "organization_id": None, @@ -5419,18 +5686,30 @@ async def test_update_team_rpm_limit_exceeds_user_limit(): mock_prisma.db.litellm_teamtable.find_unique = AsyncMock( return_value=mock_existing_team ) + mock_prisma.jsonify_team_object = lambda db_data: db_data + mock_cache.async_get_cache = AsyncMock(return_value=None) + mock_cache.async_set_cache = AsyncMock() - # Should raise ProxyException because new RPM exceeds user's limit - with pytest.raises(ProxyException) as exc_info: - await update_team( - data=update_request, - http_request=dummy_request, - user_api_key_dict=non_admin_user, - ) + mock_updated_team = MagicMock() + mock_updated_team.team_id = "team-rpm-test-123" + mock_updated_team.organization_id = None + mock_updated_team.litellm_model_table = None + mock_updated_team.model_dump.return_value = { + "team_id": "team-rpm-test-123", + "organization_id": None, + "rpm_limit": 500, + } + mock_prisma.db.litellm_teamtable.update = AsyncMock( + return_value=mock_updated_team + ) - # Verify exception details - assert exc_info.value.code == "400" - assert "rpm" in str(exc_info.value.message).lower() + result = await update_team( + data=update_request, + http_request=dummy_request, + user_api_key_dict=team_admin_user, + ) + + assert result is not None @pytest.mark.asyncio diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py index c9be00afed0..b75fc27e21d 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py @@ -1803,6 +1803,9 @@ async def test_pass_through_request_with_forward_headers_true(self): mock_logging_obj.pre_call_hook = AsyncMock(return_value=mock_request_body) mock_logging_obj.post_call_success_hook = AsyncMock() mock_logging_obj.post_call_failure_hook = AsyncMock() + mock_logging_obj.post_call_response_headers_hook = AsyncMock( + return_value={} + ) # Call pass_through_request with forward_headers=True result = await pass_through_request( @@ -1901,6 +1904,9 @@ async def test_pass_through_request_with_forward_headers_false(self): mock_logging_obj.pre_call_hook = AsyncMock(return_value=mock_request_body) mock_logging_obj.post_call_success_hook = AsyncMock() mock_logging_obj.post_call_failure_hook = AsyncMock() + mock_logging_obj.post_call_response_headers_hook = AsyncMock( + return_value={} + ) # Call pass_through_request with forward_headers=False (default) result = await pass_through_request( diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py index 9b9d5e22a43..31f1c1c57d6 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py @@ -957,6 +957,9 @@ async def test_pass_through_request_contains_proxy_server_request_in_kwargs(): return_value={"test": "data"} ) mock_proxy_logging.post_call_failure_hook = AsyncMock() + mock_proxy_logging.post_call_response_headers_hook = AsyncMock( + return_value={"x-callback-test": "value"} + ) # Setup mock for http response mock_response = MagicMock() @@ -1069,6 +1072,9 @@ async def test_pass_through_request_streaming_marks_logging_obj_as_stream(): return_value={"model": "claude-3", "stream": True} ) mock_proxy_logging.post_call_failure_hook = AsyncMock() + mock_proxy_logging.post_call_response_headers_hook = AsyncMock( + return_value={"x-callback-test": "value"} + ) upstream_response = MagicMock() upstream_response.status_code = 200 @@ -1134,6 +1140,9 @@ async def test_pass_through_request_sse_response_marks_logging_obj_as_stream(): return_value={"model": "claude-3"} ) mock_proxy_logging.post_call_failure_hook = AsyncMock() + mock_proxy_logging.post_call_response_headers_hook = AsyncMock( + return_value={"x-callback-test": "value"} + ) upstream_response = MagicMock() upstream_response.status_code = 200 @@ -1975,6 +1984,9 @@ async def test_pass_through_request_query_params_forwarding(): mock_proxy_logging.pre_call_hook = AsyncMock( return_value=test_body ) + mock_proxy_logging.post_call_response_headers_hook = AsyncMock( + return_value={"x-callback-test": "value"} + ) # Setup mock for http response mock_response = MagicMock() @@ -2703,6 +2715,10 @@ async def _hook_mutates_body(**kwargs): "litellm.proxy.proxy_server.proxy_logging_obj.pre_call_hook", new=AsyncMock(side_effect=_hook_mutates_body), ), + patch( + "litellm.proxy.proxy_server.proxy_logging_obj.post_call_response_headers_hook", + new=AsyncMock(return_value={}), + ), patch( "litellm.proxy.pass_through_endpoints.pass_through_endpoints.pass_through_endpoint_logging.pass_through_async_success_handler", new=AsyncMock(), @@ -2763,6 +2779,10 @@ async def test_pass_through_request_streaming_uses_content_for_state_raw_body(): "litellm.proxy.proxy_server.proxy_logging_obj.pre_call_hook", new=AsyncMock(side_effect=lambda **kw: kw["data"]), ), + patch( + "litellm.proxy.proxy_server.proxy_logging_obj.post_call_response_headers_hook", + new=AsyncMock(return_value={}), + ), patch( "litellm.proxy.pass_through_endpoints.pass_through_endpoints.pass_through_endpoint_logging.pass_through_async_success_handler", new=AsyncMock(), diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_passthrough_post_call_guardrails.py b/tests/test_litellm/proxy/pass_through_endpoints/test_passthrough_post_call_guardrails.py index eafe71e1063..a2f7476abd1 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_passthrough_post_call_guardrails.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_passthrough_post_call_guardrails.py @@ -130,6 +130,7 @@ async def test_post_call_success_hook_called_when_guardrails_configured( mock_proxy_logging.post_call_success_hook = AsyncMock( return_value=_GEMINI_RESPONSE ) + mock_proxy_logging.post_call_response_headers_hook = AsyncMock(return_value={}) with _common_patches(mock_proxy_logging, mock_response): await pass_through_request( @@ -155,6 +156,7 @@ async def test_post_call_success_hook_skipped_when_no_guardrails( mock_proxy_logging = MagicMock() mock_proxy_logging.pre_call_hook = AsyncMock(return_value={}) mock_proxy_logging.post_call_success_hook = AsyncMock() + mock_proxy_logging.post_call_response_headers_hook = AsyncMock(return_value={}) with _common_patches(mock_proxy_logging, mock_response): result = await pass_through_request( diff --git a/tests/test_litellm/proxy/proxy_server/test_routes_audio.py b/tests/test_litellm/proxy/proxy_server/test_routes_audio.py index d88bcf136e9..74542a3eaf6 100644 --- a/tests/test_litellm/proxy/proxy_server/test_routes_audio.py +++ b/tests/test_litellm/proxy/proxy_server/test_routes_audio.py @@ -26,6 +26,7 @@ def patched_speech(monkeypatch): MagicMock( pre_call_hook=AsyncMock(side_effect=lambda **kw: kw["data"]), post_call_failure_hook=AsyncMock(), + post_call_response_headers_hook=AsyncMock(return_value={}), update_request_status=AsyncMock(), ), ) @@ -61,6 +62,7 @@ def patched_speech_error(monkeypatch): MagicMock( pre_call_hook=AsyncMock(side_effect=lambda **kw: kw["data"]), post_call_failure_hook=AsyncMock(), + post_call_response_headers_hook=AsyncMock(return_value={}), update_request_status=AsyncMock(), ), ) diff --git a/tests/test_litellm/proxy/test_audio_speech_prometheus_hooks.py b/tests/test_litellm/proxy/test_audio_speech_prometheus_hooks.py index 5a13a4dc531..99f6f3a9b72 100644 --- a/tests/test_litellm/proxy/test_audio_speech_prometheus_hooks.py +++ b/tests/test_litellm/proxy/test_audio_speech_prometheus_hooks.py @@ -68,6 +68,7 @@ async def test_audio_speech_success_does_not_call_post_call_success_hook( mock_logging.post_call_failure_hook = mock_failure_hook mock_logging.pre_call_hook = mock_pre_call mock_logging.update_request_status = mock_update_status + mock_logging.post_call_response_headers_hook = AsyncMock(return_value={}) async def _mock_route_request(*, data, route_type, llm_router, user_model): assert route_type == "aspeech" diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 8aa839cdfcb..b2e36fd64cf 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -2319,6 +2319,36 @@ async def test_custom_ui_sso_sign_in_handler_config_loading(): os.unlink(config_file_path) +@pytest.mark.asyncio +async def test_load_config_max_budget_env_var_coerced_to_float(tmp_path, monkeypatch): + """ + max_budget configured as os.environ/MAX_BUDGET resolves to a string; + load_config must coerce it to float so the startup check + `litellm.max_budget > 0` doesn't raise TypeError. + """ + from litellm.proxy.proxy_server import ProxyConfig + + monkeypatch.setenv("MAX_BUDGET", "10") + test_config = { + "model_list": [], + "litellm_settings": {"max_budget": "os.environ/MAX_BUDGET"}, + } + config_file = tmp_path / "config.yaml" + config_file.write_text(yaml.dump(test_config)) + + original_max_budget = litellm.max_budget + try: + proxy_config = ProxyConfig() + await proxy_config.load_config( + router=MagicMock(), config_file_path=str(config_file) + ) + assert isinstance(litellm.max_budget, float) + assert litellm.max_budget == 10.0 + assert litellm.max_budget > 0 + finally: + litellm.max_budget = original_max_budget + + @pytest.mark.asyncio async def test_load_environment_variables_direct_and_os_environ(): """ diff --git a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py index ae217aca16e..f77af2d90bf 100644 --- a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py +++ b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py @@ -1032,6 +1032,45 @@ def test_update_ui_settings_allowlisted_value(self, mock_auth, monkeypatch): stored_settings = json.loads(create_data["ui_settings"]) assert stored_settings["disable_model_add_for_internal_users"] is True + def test_update_ui_settings_persists_disable_ui_nudges( + self, mock_auth, monkeypatch + ): + """disable_ui_nudges must be allowlisted so admins can suppress UI popups for everyone""" + from unittest.mock import AsyncMock, MagicMock + + from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + + mock_user_auth = UserAPIKeyAuth( + user_id="test-user-123", + user_role=LitellmUserRoles.PROXY_ADMIN, + ) + app.dependency_overrides[user_api_key_auth] = lambda: mock_user_auth + + monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True) + mock_prisma = MagicMock() + mock_prisma.db.litellm_uisettings.upsert = AsyncMock() + mock_prisma.db.litellm_uisettings.find_unique = AsyncMock(return_value=None) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma) + + try: + response = client.patch( + "/update/ui_settings", json={"disable_ui_nudges": True} + ) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + assert data["settings"]["disable_ui_nudges"] is True + + create_data = mock_prisma.db.litellm_uisettings.upsert.call_args.kwargs["data"][ + "create" + ] + stored_settings = json.loads(create_data["ui_settings"]) + assert stored_settings["disable_ui_nudges"] is True + def test_update_ui_settings_ignores_non_allowlisted_value( self, mock_auth, monkeypatch ): diff --git a/tests/test_litellm/test_claude_fable_5_config.py b/tests/test_litellm/test_claude_fable_5_config.py new file mode 100644 index 00000000000..d8d95fba0da --- /dev/null +++ b/tests/test_litellm/test_claude_fable_5_config.py @@ -0,0 +1,230 @@ +""" +Validate Claude Fable 5 model configuration entries. + +Fable 5 is a new tier above Opus ($10/$50 per MTok) with the same adaptive-only +API surface as Opus 4.7/4.8. The cost-map entries below are what make the model +resolvable across Anthropic, Bedrock, Vertex AI, and Azure AI (Microsoft +Foundry), and the ``supports_adaptive_thinking`` flag is what makes LiteLLM send +``thinking.type='adaptive'`` instead of the legacy ``enabled``/``budget_tokens`` +shape, which Fable 5 rejects with a 400. +""" + +import json +import os + +import pytest + +import litellm +from litellm.constants import BEDROCK_CONVERSE_MODELS +from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap + +REPO_ROOT = os.path.join(os.path.dirname(__file__), "../..") + + +def _load_root_cost_map() -> dict: + json_path = os.path.join(REPO_ROOT, "model_prices_and_context_window.json") + with open(json_path) as f: + return json.load(f) + + +@pytest.fixture +def local_model_cost_map(monkeypatch): + """Force the bundled backup cost map so assertions don't depend on the + network-fetched ``main`` copy (which lags this branch until merge).""" + original_model_cost = litellm.model_cost + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") + litellm.model_cost = litellm.get_model_cost_map(url="") + litellm.get_model_info.cache_clear() + try: + yield + finally: + litellm.model_cost = original_model_cost + litellm.get_model_info.cache_clear() + + +def test_fable_5_model_pricing_and_capabilities(): + model_data = _load_root_cost_map() + + expected_models = [ + ("claude-fable-5", "anthropic"), + ("anthropic.claude-fable-5", "bedrock_converse"), + ("vertex_ai/claude-fable-5", "vertex_ai-anthropic_models"), + # Unlike Opus 4.8 (200k on Foundry), Fable 5 has the full 1M context + # window on Microsoft Foundry. + ("azure_ai/claude-fable-5", "azure_ai"), + ] + + for model_name, provider in expected_models: + assert model_name in model_data, f"Missing model entry: {model_name}" + info = model_data[model_name] + + assert info["litellm_provider"] == provider + assert info["mode"] == "chat" + assert info["max_input_tokens"] == 1000000 + assert info["max_output_tokens"] == 128000 + assert info["max_tokens"] == 128000 + + # $10 / $50 per MTok (2x Opus 4.8), with the standard 1.25x 5m + # cache-write, 2x 1h cache-write, and 0.1x cache-read multipliers. + assert info["input_cost_per_token"] == 1e-05 + assert info["output_cost_per_token"] == 5e-05 + assert info["cache_creation_input_token_cost"] == 1.25e-05 + assert info["cache_creation_input_token_cost_above_1hr"] == 2e-05 + assert info["cache_read_input_token_cost"] == 1e-06 + + # Flat-rate across the full 1M context window. + assert "input_cost_per_token_above_200k_tokens" not in info + assert "output_cost_per_token_above_200k_tokens" not in info + + assert info["supports_assistant_prefill"] is False + assert info["supports_function_calling"] is True + assert info["supports_prompt_caching"] is True + assert info["supports_reasoning"] is True + assert info["supports_tool_choice"] is True + assert info["supports_vision"] is True + assert info["supports_xhigh_reasoning_effort"] is True + assert info["supports_max_reasoning_effort"] is True + + +def test_fable_5_bedrock_regional_model_pricing(): + model_data = _load_root_cost_map() + + # Fable 5 launched with us/eu geo inference profiles plus a global profile + # (no au/apac/jp). Global uses base pricing; geo profiles carry the + # standard 10% regional premium. + expected_models = { + "global.anthropic.claude-fable-5": { + "input_cost_per_token": 1e-05, + "output_cost_per_token": 5e-05, + "cache_creation_input_token_cost": 1.25e-05, + "cache_read_input_token_cost": 1e-06, + }, + "us.anthropic.claude-fable-5": { + "input_cost_per_token": 1.1e-05, + "output_cost_per_token": 5.5e-05, + "cache_creation_input_token_cost": 1.375e-05, + "cache_read_input_token_cost": 1.1e-06, + }, + "eu.anthropic.claude-fable-5": { + "input_cost_per_token": 1.1e-05, + "output_cost_per_token": 5.5e-05, + "cache_creation_input_token_cost": 1.375e-05, + "cache_read_input_token_cost": 1.1e-06, + }, + } + + for model_name, expected in expected_models.items(): + assert model_name in model_data, f"Missing model entry: {model_name}" + info = model_data[model_name] + assert info["litellm_provider"] == "bedrock_converse" + assert info["max_input_tokens"] == 1000000 + assert info["max_output_tokens"] == 128000 + assert info["bedrock_output_config_effort_ceiling"] == "xhigh" + for key, value in expected.items(): + assert info[key] == value + + +def test_fable_5_geo_multiplier_without_fast_mode(): + """First-party ``inference_geo='us'`` carries the 1.1x premium, but unlike + the Opus line there is no fast-mode variant for Fable 5; a ``fast`` key + here would silently misprice ``speed='fast'`` requests.""" + model_data = _load_root_cost_map() + entry = model_data["claude-fable-5"]["provider_specific_entry"] + assert entry == {"us": 1.1} + + +def test_fable_5_present_in_bundled_backup(): + """The bundled backup is the runtime fallback (and what tests load with + ``LITELLM_LOCAL_MODEL_COST_MAP=True``) — it must carry the same entries as + the root cost map, otherwise the model resolves on one path but not the + other.""" + backup = GetModelCostMap.load_local_model_cost_map() + root = _load_root_cost_map() + for model_name in ( + "claude-fable-5", + "anthropic.claude-fable-5", + "global.anthropic.claude-fable-5", + "us.anthropic.claude-fable-5", + "eu.anthropic.claude-fable-5", + "vertex_ai/claude-fable-5", + "vertex_ai/claude-fable-5@default", + "azure_ai/claude-fable-5", + ): + assert model_name in backup, f"Missing from backup cost map: {model_name}" + assert backup[model_name] == root[model_name], model_name + + +def test_fable_5_registered_for_bedrock_converse(): + assert "anthropic.claude-fable-5" in BEDROCK_CONVERSE_MODELS + + +def test_fable_5_provider_resolves_via_model_info(local_model_cost_map): + info = litellm.get_model_info(model="claude-fable-5") + assert info["litellm_provider"] == "anthropic" + assert info["max_input_tokens"] == 1000000 + assert info["max_output_tokens"] == 128000 + + +@pytest.mark.parametrize( + "cost_map", + [_load_root_cost_map(), GetModelCostMap.load_local_model_cost_map()], + ids=["root", "bundled_backup"], +) +def test_fable_5_all_variants_carry_adaptive_thinking_flag(cost_map): + """Every Fable 5 entry must advertise ``supports_adaptive_thinking``. + + Adaptive-thinking detection is cost-map driven, so a single variant missing + the flag silently sends the legacy ``thinking.type='enabled'`` shape and the + provider 400s (issue #29188 for the Opus 4.8 equivalent). Fable 5 is even + stricter than Opus 4.8: an explicit ``thinking.type='disabled'`` also 400s, + so adaptive is the only valid thinking shape LiteLLM can emit for it.""" + variants = [k for k in cost_map if "claude-fable-5" in k] + assert variants, "no claude-fable-5 entries found in cost map" + missing = [ + k for k in variants if cost_map[k].get("supports_adaptive_thinking") is not True + ] + assert not missing, f"missing supports_adaptive_thinking: {missing}" + + +@pytest.mark.parametrize( + "model", + [ + "claude-fable-5", + "anthropic/claude-fable-5", + "anthropic.claude-fable-5", + "bedrock/us.anthropic.claude-fable-5", + "bedrock/invoke/eu.anthropic.claude-fable-5", + "bedrock/global.anthropic.claude-fable-5", + "vertex_ai/claude-fable-5", + "azure_ai/claude-fable-5", + ], +) +def test_adaptive_thinking_detected_for_fable_5(local_model_cost_map, model): + """Provider-routed ids must resolve to a flagged entry so ``reasoning_effort`` + maps to ``thinking.type='adaptive'`` + ``output_config.effort``.""" + from litellm.llms.anthropic.common_utils import AnthropicModelInfo + + assert AnthropicModelInfo._is_adaptive_thinking_model(model) is True + + +@pytest.mark.parametrize( + "cost_map", + [_load_root_cost_map(), GetModelCostMap.load_local_model_cost_map()], + ids=["root", "bundled_backup"], +) +def test_sampling_params_flag_on_all_models_that_removed_them(cost_map): + """Fable 5 and Opus 4.7/4.8 reject ``top_p``/``top_k``/``temperature != 1``; + the drop/raise gating is cost-map driven, so every variant must carry an + explicit ``supports_sampling_params: false``. The perplexity route is + exempt: it is OpenAI-compatible and maps sampling params upstream.""" + variants = [ + k + for k in cost_map + if any(v in k for v in ("claude-fable-5", "claude-opus-4-7", "claude-opus-4-8")) + and not k.startswith("perplexity/") + ] + assert variants, "no matching entries found in cost map" + missing = [ + k for k in variants if cost_map[k].get("supports_sampling_params") is not False + ] + assert not missing, f"missing supports_sampling_params=false: {missing}" diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index f179e9c8f93..4c4d9e1133b 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -858,6 +858,7 @@ def test_aaamodel_prices_and_context_window_json_is_valid(): "supports_xhigh_reasoning_effort": {"type": "boolean"}, "supports_max_reasoning_effort": {"type": "boolean"}, "supports_adaptive_thinking": {"type": "boolean"}, + "supports_sampling_params": {"type": "boolean"}, "supports_service_tier": {"type": "boolean"}, "supports_preset": {"type": "boolean"}, "supports_output_config": {"type": "boolean"}, diff --git a/ui/litellm-dashboard/e2e_tests/fixtures/migratedPages.ts b/ui/litellm-dashboard/e2e_tests/fixtures/migratedPages.ts new file mode 100644 index 00000000000..f2ba66147ea --- /dev/null +++ b/ui/litellm-dashboard/e2e_tests/fixtures/migratedPages.ts @@ -0,0 +1,16 @@ +/** + * Source of truth for the App Router migration smoke (tests/migration/migratedPages.spec.ts). + * + * Add a route segment here once its migration has MERGED to the branch under test. + * Both suites pick it up automatically: + * - default mount: npm run e2e:migration + * - server-root-path mount: SERVER_ROOT_PATH=/ npm run e2e:migration:root + * + * Keep this in lockstep with MIGRATED_PAGES in src/utils/migratedPages.ts. + * Pending (uncomment as each PR lands): playground, and the leaf-pages batch + * (budgets, caching, cost-tracking, guardrails, guardrails-monitor, logs, + * mcp-servers, memory, policies, projects, prompts, search-tools, skills, + * tag-management, tool-policies, transform-request, ui-theme, vector-stores, + * workflows, access-groups). + */ +export const MIGRATED_E2E_SEGMENTS: string[] = ["api-reference"]; diff --git a/ui/litellm-dashboard/e2e_tests/globalSetup.ts b/ui/litellm-dashboard/e2e_tests/globalSetup.ts index 8f80f57bd78..0b3fa7e8807 100644 --- a/ui/litellm-dashboard/e2e_tests/globalSetup.ts +++ b/ui/litellm-dashboard/e2e_tests/globalSetup.ts @@ -4,17 +4,18 @@ import * as fs from "fs"; async function globalSetup() { const browser = await chromium.launch(); + const rootPath = process.env.SERVER_ROOT_PATH ?? ""; for (const role of Object.values(Role)) { const { email, password } = users[role]; const storagePath = STORAGE_PATHS[role]; const page = await browser.newPage(); try { - await page.goto("http://localhost:4000/ui/login"); + await page.goto(`http://localhost:4000${rootPath}/ui/login`); await page.getByPlaceholder("Enter your username").fill(email); await page.getByPlaceholder("Enter your password").fill(password); await page.getByRole("button", { name: "Login", exact: true }).click(); - await page.waitForURL((url) => url.pathname.startsWith("/ui") && !url.pathname.includes("/login"), { + await page.waitForURL((url) => url.pathname.startsWith(`${rootPath}/ui`) && !url.pathname.includes("/login"), { timeout: 30_000, }); await expect(page.locator("a", { hasText: "Virtual Keys" })).toBeVisible({ timeout: 30_000 }); diff --git a/ui/litellm-dashboard/e2e_tests/migration.serverRootPath.config.ts b/ui/litellm-dashboard/e2e_tests/migration.serverRootPath.config.ts new file mode 100644 index 00000000000..205348463c8 --- /dev/null +++ b/ui/litellm-dashboard/e2e_tests/migration.serverRootPath.config.ts @@ -0,0 +1,38 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * App Router migration smoke under a non-root mount. Boot the proxy with the same + * SERVER_ROOT_PATH (e.g. SERVER_ROOT_PATH=/litellm) and a UI built for it before + * running. globalSetup logs in at `${SERVER_ROOT_PATH}/ui/login` so the admin + * storage state is valid under the prefix. + */ +if (!process.env.SERVER_ROOT_PATH) { + throw new Error( + "migration.serverRootPath.config.ts requires SERVER_ROOT_PATH to be set (e.g. SERVER_ROOT_PATH=/litellm). " + + "Without it this config silently re-runs the default mount and never exercises the prefix. " + + "For the root-less run use the default playwright.config.ts (npm run e2e:migration).", + ); +} + +export default defineConfig({ + testDir: "./tests/migration", + testMatch: ["migratedPages.spec.ts"], + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: "list", + use: { + baseURL: "http://localhost:4000", + trace: "on-first-retry", + actionTimeout: 15 * 1000, + navigationTimeout: 30 * 1000, + launchOptions: { + slowMo: process.env.SLOWMO ? parseInt(process.env.SLOWMO, 10) || 0 : 0, + }, + }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], + timeout: 3 * 60 * 1000, + expect: { timeout: 10 * 1000 }, + globalSetup: require.resolve("./globalSetup"), +}); diff --git a/ui/litellm-dashboard/e2e_tests/tests/migration/README.md b/ui/litellm-dashboard/e2e_tests/tests/migration/README.md new file mode 100644 index 00000000000..4b3a391d421 --- /dev/null +++ b/ui/litellm-dashboard/e2e_tests/tests/migration/README.md @@ -0,0 +1,33 @@ +# App Router migration smoke + +A growing E2E smoke for pages migrated from the legacy `?page=` switch to App +Router path routes. For each migrated page it clicks the page's sidebar link, checks +the URL is the path route and the page renders, reloads it, then clicks off to a +legacy page and back to confirm navigation still works. It runs in two situations: +the default mount and a non-root `SERVER_ROOT_PATH` mount. + +## Adding a page + +When a page's migration merges, add its route segment to +`e2e_tests/fixtures/migratedPages.ts` (keep it in lockstep with `MIGRATED_PAGES` +in `src/utils/migratedPages.ts`). Both suites pick it up automatically. + +## Running + +Build the UI into the proxy and start the proxy first (the suite runs against +`http://localhost:4000`). + +Default mount: + +``` +npm run e2e:migration +``` + +Non-root mount (build and boot the proxy with the same root path, e.g. `/litellm`): + +``` +SERVER_ROOT_PATH=/litellm npm run e2e:migration:root +``` + +`globalSetup` logs in once per role; the admin storage state is reused for these +tests. Under a non-root mount it logs in at `${SERVER_ROOT_PATH}/ui/login`. diff --git a/ui/litellm-dashboard/e2e_tests/tests/migration/migratedPages.spec.ts b/ui/litellm-dashboard/e2e_tests/tests/migration/migratedPages.spec.ts new file mode 100644 index 00000000000..98f4fee1450 --- /dev/null +++ b/ui/litellm-dashboard/e2e_tests/tests/migration/migratedPages.spec.ts @@ -0,0 +1,101 @@ +import { test, expect, type Page } from "@playwright/test"; +import { MIGRATED_E2E_SEGMENTS } from "../../fixtures/migratedPages"; +import { ADMIN_STORAGE_PATH } from "../../constants"; +import { dismissFeedbackPopup } from "../../helpers/navigation"; + +/** + * App Router migration smoke as a user journey: start where the proxy lands you, + * click a migrated page in the sidebar, confirm it routed and rendered, reload it + * (the check a wrong server_root_path breaks), bounce to a legacy page and back, + * and, once two pages are migrated, navigate directly between two migrated pages. + * + * Driven by MIGRATED_E2E_SEGMENTS, so it grows as pages are migrated. Set + * SERVER_ROOT_PATH (e.g. "/litellm") to exercise the non-root mount; leave it + * unset for the default mount. Boot the proxy with the matching value first. + */ +const ROOT = process.env.SERVER_ROOT_PATH ?? ""; + +const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +const pathRe = (segment: string) => new RegExp(`${esc(ROOT)}/ui/${esc(segment)}/?($|\\?)`); +const legacyAnchor = (page: Page) => page.locator("a", { hasText: "Virtual Keys" }); + +/** The dashboard shell is present (sidebar rendered); page didn't 404 / crash. */ +async function expectRendered(page: Page) { + await expect(legacyAnchor(page)).toBeVisible({ timeout: 20_000 }); +} + +/** + * Click a migrated page's sidebar link. Migrated items render as ; + * nested ones live under collapsible submenus, so expand submenus until the link is clickable. + */ +async function clickSidebar(page: Page, segment: string) { + const link = page.locator(`a[href$="/ui/${segment}"]`).first(); + for (let i = 0; i < 8 && !(await link.isVisible().catch(() => false)); i++) { + const collapsedSubmenu = page + .locator(".ant-menu-submenu:not(.ant-menu-submenu-open) > .ant-menu-submenu-title") + .first(); + if (!(await collapsedSubmenu.isVisible().catch(() => false))) break; + await collapsedSubmenu.click(); + await page.waitForTimeout(250); + } + await link.click(); +} + +test.use({ storageState: ADMIN_STORAGE_PATH }); + +test.describe("App Router migrated pages", () => { + for (const segment of MIGRATED_E2E_SEGMENTS) { + test(`${segment}: sidebar nav, reload, and round-trip with a legacy page`, async ({ page }) => { + const pageErrors: string[] = []; + page.on("pageerror", (e) => pageErrors.push(String(e))); + + // 1. Start where the proxy lands us. + await page.goto(`${ROOT}/ui/`); + await dismissFeedbackPopup(page); + await expectRendered(page); + + // 2. Click the migrated page in the sidebar -> path route + rendered. + await clickSidebar(page, segment); + await expect(page).toHaveURL(pathRe(segment)); + await expectRendered(page); + // 3. Reload the path route directly; a wrong server_root_path 404s here. + await page.reload(); + await dismissFeedbackPopup(page); + await expect(page).toHaveURL(pathRe(segment)); + await expectRendered(page); + // 4. Click off to a legacy (not-yet-migrated) page. + await legacyAnchor(page).click(); + await expect(page).toHaveURL(new RegExp(`${esc(ROOT)}/ui/\\?page=api-keys`)); + await dismissFeedbackPopup(page); + await expectRendered(page); + // 5. Click back to the migrated page. + await clickSidebar(page, segment); + await expect(page).toHaveURL(pathRe(segment)); + await expectRendered(page); + expect(pageErrors, `page errors during ${segment} journey`).toEqual([]); + }); + } + + test("navigates directly between two migrated pages", async ({ page }) => { + test.skip(MIGRATED_E2E_SEGMENTS.length < 2, "needs >= 2 migrated pages"); + const [first, second] = MIGRATED_E2E_SEGMENTS; + const pageErrors: string[] = []; + page.on("pageerror", (e) => pageErrors.push(String(e))); + + await page.goto(`${ROOT}/ui/`); + await dismissFeedbackPopup(page); + + await clickSidebar(page, first); + await expect(page).toHaveURL(pathRe(first)); + await expectRendered(page); + await clickSidebar(page, second); + await expect(page).toHaveURL(pathRe(second)); + await expectRendered(page); + // Back to the first migrated page. + await clickSidebar(page, first); + await expect(page).toHaveURL(pathRe(first)); + await expectRendered(page); + + expect(pageErrors, "page errors during migrated -> migrated nav").toEqual([]); + }); +}); diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index b7dd2a6f59b..568f6b288d5 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -11,7 +11,6 @@ "@anthropic-ai/sdk": "0.92.0", "@headlessui/tailwindcss": "0.2.2", "@heroicons/react": "1.0.6", - "@remixicon/react": "4.9.0", "@tanstack/react-pacer": "0.2.0", "@tanstack/react-query": "5.100.7", "@tanstack/react-table": "8.21.3", @@ -44,18 +43,15 @@ "@testing-library/jest-dom": "6.9.1", "@testing-library/react": "16.3.2", "@testing-library/user-event": "14.6.1", - "@types/babel__traverse": "7.28.0", "@types/lodash": "4.17.23", "@types/node": "20.19.37", "@types/react": "18.2.48", "@types/react-copy-to-clipboard": "5.0.7", "@types/react-dom": "18.3.7", "@types/react-syntax-highlighter": "15.5.13", - "@types/uuid": "10.0.0", "@vitest/coverage-v8": "3.2.4", "@vitest/ui": "3.2.4", "autoprefixer": "10.4.24", - "dotenv": "17.2.3", "eslint": "9.39.2", "eslint-config-next": "16.2.6", "eslint-config-prettier": "10.1.8", @@ -68,7 +64,6 @@ "tailwindcss": "3.4.19", "typescript": "5.9.3", "typescript-eslint": "8.60.1", - "vite": "7.3.2", "vitest": "3.2.4" }, "engines": { @@ -2847,15 +2842,6 @@ "npm": ">=9.5.0" } }, - "node_modules/@remixicon/react": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@remixicon/react/-/react-4.9.0.tgz", - "integrity": "sha512-5/jLDD4DtKxH2B4QVXTobvV1C2uL8ab9D5yAYNtFt+w80O0Ys1xFOrspqROL3fjrZi+7ElFUWE37hBfaAl6U+Q==", - "license": "Remix Icon License 1.0", - "peerDependencies": { - "react": ">=18.2.0" - } - }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.3", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", @@ -3490,16 +3476,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -3737,13 +3713,6 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, - "node_modules/@types/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.60.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz", @@ -5912,19 +5881,6 @@ "csstype": "^3.0.2" } }, - "node_modules/dotenv": { - "version": "17.2.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", - "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json index 623389e76ed..eb6211a91d1 100644 --- a/ui/litellm-dashboard/package.json +++ b/ui/litellm-dashboard/package.json @@ -16,6 +16,8 @@ "format:check": "prettier --check .", "e2e": "playwright test --config e2e_tests/playwright.config.ts", "e2e:ui": "playwright test --ui --config e2e_tests/playwright.config.ts", + "e2e:migration": "playwright test e2e_tests/tests/migration/migratedPages.spec.ts --config e2e_tests/playwright.config.ts", + "e2e:migration:root": "playwright test --config e2e_tests/migration.serverRootPath.config.ts", "knip": "knip", "knip:fix": "knip --fix", "gen:api": "node scripts/gen-api-types.mjs" @@ -24,7 +26,6 @@ "@anthropic-ai/sdk": "0.92.0", "@headlessui/tailwindcss": "0.2.2", "@heroicons/react": "1.0.6", - "@remixicon/react": "4.9.0", "@tanstack/react-pacer": "0.2.0", "@tanstack/react-query": "5.100.7", "@tanstack/react-table": "8.21.3", @@ -57,18 +58,15 @@ "@testing-library/jest-dom": "6.9.1", "@testing-library/react": "16.3.2", "@testing-library/user-event": "14.6.1", - "@types/babel__traverse": "7.28.0", "@types/lodash": "4.17.23", "@types/node": "20.19.37", "@types/react": "18.2.48", "@types/react-copy-to-clipboard": "5.0.7", "@types/react-dom": "18.3.7", "@types/react-syntax-highlighter": "15.5.13", - "@types/uuid": "10.0.0", "@vitest/coverage-v8": "3.2.4", "@vitest/ui": "3.2.4", "autoprefixer": "10.4.24", - "dotenv": "17.2.3", "eslint": "9.39.2", "eslint-config-next": "16.2.6", "eslint-config-prettier": "10.1.8", @@ -81,7 +79,6 @@ "tailwindcss": "3.4.19", "typescript": "5.9.3", "typescript-eslint": "8.60.1", - "vite": "7.3.2", "vitest": "3.2.4" }, "overrides": { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx deleted file mode 100644 index 77496aef3e6..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx +++ /dev/null @@ -1,26 +0,0 @@ -"use client"; - -import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; -import useTeams from "@/app/(dashboard)/hooks/useTeams"; -import { useState } from "react"; -import ModelsAndEndpointsView from "@/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView"; - -const ModelsAndEndpointsPage = () => { - const { token, premiumUser } = useAuthorized(); - const [keys, setKeys] = useState([]); - - const { teams } = useTeams(); - - return ( - {}} - premiumUser={premiumUser} - teams={teams} - /> - ); -}; - -export default ModelsAndEndpointsPage; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/page.tsx deleted file mode 100644 index 6112fac3161..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/organizations/page.tsx +++ /dev/null @@ -1,34 +0,0 @@ -"use client"; - -import Organizations, { fetchOrganizations } from "@/components/organizations"; -import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; -import { useEffect, useState } from "react"; -import { Organization } from "@/components/networking"; -import { fetchUserModels } from "@/components/organisms/create_key_button"; - -const OrganizationsPage = () => { - const { userId: userID, accessToken, userRole, premiumUser } = useAuthorized(); - const [organizations, setOrganizations] = useState([]); - const [userModels, setUserModels] = useState([]); - - useEffect(() => { - fetchOrganizations(accessToken, setOrganizations).then(() => {}); - }, [accessToken]); - - useEffect(() => { - fetchUserModels(userID, userRole, accessToken, setUserModels).then(() => {}); - }, [userID, userRole, accessToken]); - - return ( - - ); -}; - -export default OrganizationsPage; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/page.tsx deleted file mode 100644 index 226616474eb..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/page.tsx +++ /dev/null @@ -1,47 +0,0 @@ -"use client"; - -import { useState } from "react"; -import useKeyList from "@/components/key_team_helpers/key_list"; -import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; -import UserDashboard from "@/components/user_dashboard"; -import useTeams from "@/app/(dashboard)/hooks/useTeams"; -import { Organization } from "@/components/networking"; - -const VirtualKeysPage = () => { - const { accessToken, userRole, userId, premiumUser, userEmail } = useAuthorized(); - const { teams, setTeams } = useTeams(); - const [createClicked, setCreateClicked] = useState(false); - const [organizations, setOrganizations] = useState([]); - - const { keys, isLoading, error, pagination, refresh, setKeys } = useKeyList({ - selectedKeyAlias: null, - currentOrg: null, - accessToken: accessToken || "", - createClicked, - }); - - const addKey = (data: any) => { - setKeys((prevData) => (prevData ? [...prevData, data] : [data])); - setCreateClicked(() => !createClicked); - }; - - return ( - {}} - setUserEmail={() => {}} - setTeams={setTeams} - setKeys={setKeys} - premiumUser={premiumUser} - organizations={organizations} - addKey={addKey} - createClicked={createClicked} - /> - ); -}; - -export default VirtualKeysPage; diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index 12dd39a1c21..81cce930998 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -9,6 +9,7 @@ import BudgetPanel from "@/components/budgets/budget_panel"; import CacheDashboard from "@/components/cache_dashboard"; import ClaudeCodePluginsPanel from "@/components/claude_code_plugins"; import { teamListCall as v2TeamListCall } from "@/app/(dashboard)/hooks/teams/useTeams"; +import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; import LoadingScreen from "@/components/common_components/LoadingScreen"; import { CostTrackingSettings } from "@/components/CostTrackingSettings"; import GeneralSettings from "@/components/general_settings"; @@ -83,6 +84,9 @@ function CreateKeyPageContent() { const [modelData, setModelData] = useState({ data: [] }); const [createClicked, setCreateClicked] = useState(false); + const { data: uiSettingsData, isLoading: uiSettingsLoading } = useUISettings(); + const nudgesDisabled = uiSettingsLoading || Boolean(uiSettingsData?.values?.disable_ui_nudges); + // Survey state - always show by default const [showSurveyPrompt, setShowSurveyPrompt] = useState(true); const [showSurveyModal, setShowSurveyModal] = useState(false); @@ -258,6 +262,9 @@ function CreateKeyPageContent() { // Fetch in-product nudges configuration from backend useEffect(() => { + if (nudgesDisabled) { + return; + } if (accessToken && token) { (async () => { try { @@ -277,7 +284,7 @@ function CreateKeyPageContent() { } })(); } - }, [accessToken, token]); + }, [accessToken, token, nudgesDisabled]); // Auto-dismiss survey prompt after 15 seconds useEffect(() => { @@ -541,7 +548,7 @@ function CreateKeyPageContent() { {/* Survey Components */} @@ -553,7 +560,7 @@ function CreateKeyPageContent() { {/* Claude Code Components */} diff --git a/ui/litellm-dashboard/src/components/AIHub/ClaudeCodeMarketplaceTab.tsx b/ui/litellm-dashboard/src/components/AIHub/ClaudeCodeMarketplaceTab.tsx deleted file mode 100644 index 762b0836921..00000000000 --- a/ui/litellm-dashboard/src/components/AIHub/ClaudeCodeMarketplaceTab.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { SearchOutlined } from "@ant-design/icons"; -import { Card, Tab, TabGroup, TabList, TabPanel, TabPanels, Text } from "@tremor/react"; -import { Input } from "antd"; -import React, { useEffect, useMemo, useState } from "react"; -import { extractCategories, filterPluginsByCategory, filterPluginsBySearch } from "../claude_code_plugins/helpers"; -import { MarketplaceResponse } from "../claude_code_plugins/types"; -import { ModelDataTable } from "../model_dashboard/table"; -import NotificationsManager from "../molecules/notifications_manager"; -import { getClaudeCodeMarketplace } from "../networking"; -import { getMarketplaceTableColumns } from "./marketplace_table_columns"; - -interface ClaudeCodeMarketplaceTabProps { - publicPage?: boolean; -} - -const ClaudeCodeMarketplaceTab: React.FC = ({ publicPage = false }) => { - const [marketplaceData, setMarketplaceData] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [searchTerm, setSearchTerm] = useState(""); - const [selectedCategoryIndex, setSelectedCategoryIndex] = useState(0); - - useEffect(() => { - fetchMarketplace(); - }, []); - - const fetchMarketplace = async () => { - setIsLoading(true); - try { - const data: MarketplaceResponse = await getClaudeCodeMarketplace(); - console.log("Claude Code marketplace:", data); - setMarketplaceData(data); - } catch (error) { - console.error("Error fetching marketplace:", error); - } finally { - setIsLoading(false); - } - }; - - const copyToClipboard = (text: string) => { - navigator.clipboard.writeText(text); - NotificationsManager.success("Copied to clipboard!"); - }; - - // Extract unique categories from plugins - const categories = useMemo(() => { - if (!marketplaceData) return ["All"]; - return extractCategories(marketplaceData.plugins); - }, [marketplaceData]); - - // Get selected category name - const selectedCategory = categories[selectedCategoryIndex] || "All"; - - // Filter plugins by search and category - const filteredPlugins = useMemo(() => { - if (!marketplaceData) return []; - - let plugins = marketplaceData.plugins; - - // Apply category filter - plugins = filterPluginsByCategory(plugins, selectedCategory); - - // Apply search filter - plugins = filterPluginsBySearch(plugins, searchTerm); - - return plugins; - }, [marketplaceData, selectedCategory, searchTerm]); - - const columns = useMemo(() => getMarketplaceTableColumns(copyToClipboard, publicPage), [publicPage]); - - if (!marketplaceData && !isLoading) { - return ( - -
- Failed to load marketplace. Please try again later. -
-
- ); - } - - return ( -
- {/* Search Bar */} -
- } - value={searchTerm} - onChange={(e) => setSearchTerm(e.target.value)} - allowClear - size="large" - /> -
- - {/* Category Tabs */} - - - {categories.map((category) => { - // Count plugins in this category - const categoryPlugins = filterPluginsByCategory(marketplaceData?.plugins || [], category); - const count = filterPluginsBySearch(categoryPlugins, searchTerm).length; - - return ( - - {category} {count > 0 && `(${count})`} - - ); - })} - - - - {categories.map((category) => ( - - - {/* Plugin Table */} - - - - {/* Footer Info */} -
- - Showing {filteredPlugins.length} of {marketplaceData?.plugins.length || 0} plugin - {marketplaceData?.plugins.length !== 1 ? "s" : ""} - {searchTerm && ` matching "${searchTerm}"`} - {selectedCategory !== "All" && ` in ${selectedCategory}`} - -
-
- ))} -
-
-
- ); -}; - -export default ClaudeCodeMarketplaceTab; diff --git a/ui/litellm-dashboard/src/components/AIHub/marketplace_table_columns.tsx b/ui/litellm-dashboard/src/components/AIHub/marketplace_table_columns.tsx deleted file mode 100644 index fa383197b3d..00000000000 --- a/ui/litellm-dashboard/src/components/AIHub/marketplace_table_columns.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import { ColumnDef } from "@tanstack/react-table"; -import { Button, Badge, Text } from "@tremor/react"; -import { Tooltip } from "antd"; -import { CopyOutlined } from "@ant-design/icons"; -import { MarketplacePluginEntry } from "@/components/claude_code_plugins/types"; -import { - formatInstallCommand, - getCategoryBadgeColor, - getSourceDisplayText, -} from "@/components/claude_code_plugins/helpers"; - -export const getMarketplaceTableColumns = ( - copyToClipboard: (text: string) => void, - publicPage: boolean = false, -): ColumnDef[] => { - const allColumns: ColumnDef[] = [ - { - header: "Plugin Name", - accessorKey: "name", - enableSorting: true, - sortingFn: "alphanumeric", - cell: ({ row }) => { - const plugin = row.original; - const installCommand = formatInstallCommand(plugin); - - return ( -
-
- {plugin.name} - - copyToClipboard(installCommand)} - className="cursor-pointer text-gray-500 hover:text-blue-500 text-xs" - /> - -
- {/* Show description on mobile */} -
- {plugin.description || "No description"} -
-
- ); - }, - }, - { - header: "Description", - accessorKey: "description", - enableSorting: true, - sortingFn: "alphanumeric", - cell: ({ row }) => { - const plugin = row.original; - - return {plugin.description || "-"}; - }, - meta: { - className: "hidden md:table-cell", - }, - }, - { - header: "Version", - accessorKey: "version", - enableSorting: true, - sortingFn: "alphanumeric", - cell: ({ row }) => { - const plugin = row.original; - - return plugin.version ? ( - - v{plugin.version} - - ) : ( - - - ); - }, - meta: { - className: "hidden lg:table-cell", - }, - }, - { - header: "Category", - accessorKey: "category", - enableSorting: true, - sortingFn: "alphanumeric", - cell: ({ row }) => { - const plugin = row.original; - const badgeColor = getCategoryBadgeColor(plugin.category); - - return plugin.category ? ( - - {plugin.category} - - ) : ( - - Uncategorized - - ); - }, - meta: { - className: "hidden lg:table-cell", - }, - }, - { - header: "Source", - accessorKey: "source", - enableSorting: false, - cell: ({ row }) => { - const plugin = row.original; - const sourceText = getSourceDisplayText(plugin.source); - - return {sourceText}; - }, - meta: { - className: "hidden xl:table-cell", - }, - }, - { - header: "Keywords", - accessorKey: "keywords", - enableSorting: false, - cell: ({ row }) => { - const plugin = row.original; - const keywords = plugin.keywords?.slice(0, 3) || []; - const remaining = (plugin.keywords?.length || 0) - 3; - - return ( -
- {keywords.map((keyword, index) => ( - - {keyword} - - ))} - {remaining > 0 && ( - - +{remaining} - - )} -
- ); - }, - meta: { - className: "hidden xl:table-cell", - }, - }, - { - header: "Install Command", - id: "install_command", - enableSorting: false, - cell: ({ row }) => { - const plugin = row.original; - const installCommand = formatInstallCommand(plugin); - - return ( -
- - {installCommand} - - -
- ); - }, - }, - ]; - - return allColumns; -}; diff --git a/ui/litellm-dashboard/src/components/Projects/types.ts b/ui/litellm-dashboard/src/components/Projects/types.ts deleted file mode 100644 index 51429902dff..00000000000 --- a/ui/litellm-dashboard/src/components/Projects/types.ts +++ /dev/null @@ -1,14 +0,0 @@ -export interface Project { - id: string; - name: string; - description: string; - teamId: string; - teamAlias: string; - models: string[]; - status: "active" | "blocked"; - spend: number; - createdAt: string; - createdBy: string; - updatedAt: string; - updatedBy: string; -} diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx index 9ce0d908838..25865c48f9b 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx @@ -26,6 +26,7 @@ export default function UISettings() { const allowVectorStoresTeamAdminsProperty = schema?.properties?.allow_vector_stores_for_team_admins; const scopeUserSearchProperty = schema?.properties?.scope_user_search_to_org; const disableCustomApiKeysProperty = schema?.properties?.disable_custom_api_keys; + const disableUINudgesProperty = schema?.properties?.disable_ui_nudges; const values = data?.values ?? {}; const isDisabledForInternalUsers = Boolean(values.disable_model_add_for_internal_users); const isDisabledTeamAdminDeleteTeamUser = Boolean(values.disable_team_admin_delete_team_user); @@ -60,6 +61,20 @@ export default function UISettings() { ); }; + const handleToggleDisableUINudges = (checked: boolean) => { + updateSettings( + { disable_ui_nudges: checked }, + { + onSuccess: () => { + NotificationManager.success("UI settings updated successfully"); + }, + onError: (error) => { + NotificationManager.fromBackend(error); + }, + }, + ); + }; + const handleUpdatePageVisibility = (settings: { enabled_ui_pages_internal_users: string[] | null }) => { updateSettings(settings, { onSuccess: () => { @@ -451,6 +466,26 @@ export default function UISettings() { + {/* Disable in-product UI nudges */} + + + + Disable UI nudges + + {disableUINudgesProperty?.description ?? + "If true, suppresses in-product UI nudges (survey and Claude Code feedback popups) for all users."} + + + + + + {/* Page Visibility for Internal Users */} void; - accessToken: string | null; - onAgentUpdated: () => void; - isAdmin: boolean; - onAgentClick: (agentId: string) => void; -} - -const AgentTable: React.FC = ({ - agentsList, - isLoading, - onDeleteClick, - accessToken, - onAgentUpdated, - isAdmin, - onAgentClick, -}) => { - const [sorting, setSorting] = useState([{ id: "created_at", desc: true }]); - - const formatDate = (dateString?: string) => { - if (!dateString) return "-"; - const date = new Date(dateString); - return date.toLocaleString(); - }; - - const copyToClipboard = (text: string) => { - navigator.clipboard.writeText(text); - }; - - const columns: ColumnDef[] = [ - { - header: "Agent Name", - accessorKey: "agent_name", - cell: ({ row }) => { - const agent = row.original; - const name = agent.agent_name || ""; - return ( -
- - - - - { - e.stopPropagation(); - copyToClipboard(agent.agent_id); - }} - className="cursor-pointer text-gray-500 hover:text-blue-500 text-xs" - /> - -
- ); - }, - }, - { - header: "Description", - accessorKey: "agent_card_params.description", - cell: ({ row }) => { - const description = row.original.agent_card_params?.description || "No description"; - return {description}; - }, - }, - { - header: "Created At", - accessorKey: "created_at", - cell: ({ row }) => { - const agent = row.original; - return ( - - {formatDate(agent.created_at)} - - ); - }, - }, - ...(isAdmin - ? [ - { - header: "Actions", - id: "actions", - enableSorting: false, - cell: ({ row }: any) => { - const agent = row.original; - - return ( -
- -
- ); - }, - }, - ] - : []), - ]; - - const table = useReactTable({ - data: agentsList, - columns, - state: { - sorting, - }, - onSortingChange: setSorting, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - enableSorting: true, - }); - - return ( -
-
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - -
-
- {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} -
-
- {header.column.getIsSorted() ? ( - { - asc: , - desc: , - }[header.column.getIsSorted() as string] - ) : ( - - )} -
-
-
- ))} -
- ))} -
- - {isLoading ? ( - - -
-

Loading...

-
-
-
- ) : agentsList && agentsList.length > 0 ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - )) - ) : ( - - -
-

No agents found. Create one to get started.

-
-
-
- )} -
-
-
-
- ); -}; - -export default AgentTable; diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/plugin_info.tsx b/ui/litellm-dashboard/src/components/claude_code_plugins/plugin_info.tsx deleted file mode 100644 index 55347025201..00000000000 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/plugin_info.tsx +++ /dev/null @@ -1,313 +0,0 @@ -import { CopyOutlined } from "@ant-design/icons"; -import { ArrowLeftIcon, ExternalLinkIcon } from "@heroicons/react/outline"; -import { Badge, Button, Card, Grid, Text, Title } from "@tremor/react"; -import { Spin, Switch, Tooltip } from "antd"; -import React, { useEffect, useState } from "react"; -import NotificationsManager from "../molecules/notifications_manager"; -import { disableClaudeCodePlugin, enableClaudeCodePlugin, getClaudeCodePluginDetails } from "../networking"; -import { - formatDateString, - formatInstallCommand, - getCategoryBadgeColor, - getSourceDisplayText, - getSourceLink, -} from "./helpers"; -import { Plugin } from "./types"; - -interface PluginInfoViewProps { - pluginId: string; - onClose: () => void; - accessToken: string | null; - isAdmin: boolean; - onPluginUpdated: () => void; -} - -const PluginInfoView: React.FC = ({ - pluginId, - onClose, - accessToken, - isAdmin, - onPluginUpdated, -}) => { - const [plugin, setPlugin] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [isToggling, setIsToggling] = useState(false); - - useEffect(() => { - fetchPluginInfo(); - }, [pluginId, accessToken]); - - const fetchPluginInfo = async () => { - if (!accessToken) return; - - setIsLoading(true); - try { - // The backend expects plugin name, not ID - // We'll need to find the plugin by ID from the list - // For now, assume pluginId is actually the plugin name - const data = await getClaudeCodePluginDetails(accessToken, pluginId as string); - setPlugin(data.plugin); - } catch (error) { - console.error("Error fetching plugin info:", error); - NotificationsManager.error("Failed to load plugin information"); - } finally { - setIsLoading(false); - } - }; - - const handleToggleEnabled = async () => { - if (!accessToken || !plugin) return; - - setIsToggling(true); - try { - if (plugin.enabled) { - await disableClaudeCodePlugin(accessToken, plugin.name); - NotificationsManager.success(`Plugin "${plugin.name}" disabled`); - } else { - await enableClaudeCodePlugin(accessToken, plugin.name); - NotificationsManager.success(`Plugin "${plugin.name}" enabled`); - } - onPluginUpdated(); - fetchPluginInfo(); - } catch (error) { - NotificationsManager.error("Failed to toggle plugin status"); - } finally { - setIsToggling(false); - } - }; - - const copyToClipboard = (text: string) => { - navigator.clipboard.writeText(text); - NotificationsManager.success("Copied to clipboard!"); - }; - - if (isLoading) { - return ( -
- -
- ); - } - - if (!plugin) { - return ( -
-

Plugin not found

- -
- ); - } - - const installCommand = formatInstallCommand(plugin); - const sourceLink = getSourceLink(plugin.source); - const categoryBadgeColor = getCategoryBadgeColor(plugin.category); - - return ( -
- {/* Header with Back Button */} -
- -

{plugin.name}

- {plugin.version && ( - - v{plugin.version} - - )} - {plugin.category && ( - - {plugin.category} - - )} - - {plugin.enabled ? "Enabled" : "Disabled"} - -
- - {/* Install Command */} - -
-
- Install Command -
{installCommand}
-
- - - -
-
- - {/* Plugin Details */} - - Plugin Details - - {/* Plugin ID */} -
- Plugin ID -
- {plugin.id} - copyToClipboard(plugin.id)} - /> -
-
- - {/* Name */} -
- Name - {plugin.name} -
- - {/* Version */} -
- Version - {plugin.version || "N/A"} -
- - {/* Source */} -
- Source -
- {getSourceDisplayText(plugin.source)} - {sourceLink && ( - - - - )} -
-
- - {/* Category */} -
- Category -
- {plugin.category ? ( - - {plugin.category} - - ) : ( - Uncategorized - )} -
-
- - {/* Enabled Status */} - {isAdmin && ( -
- Status -
- - - {plugin.enabled - ? "Plugin is enabled and visible in marketplace" - : "Plugin is disabled and hidden from marketplace"} - -
-
- )} - - - - {/* Description */} - {plugin.description && ( - - Description - {plugin.description} - - )} - - {/* Keywords */} - {plugin.keywords && plugin.keywords.length > 0 && ( - - Keywords -
- {plugin.keywords.map((keyword, index) => ( - - {keyword} - - ))} -
-
- )} - - {/* Author Information */} - {plugin.author && ( - - Author Information - - {plugin.author.name && ( -
- Name - {plugin.author.name} -
- )} - {plugin.author.email && ( -
- Email - - - {plugin.author.email} - - -
- )} -
-
- )} - - {/* Additional Links */} - {plugin.homepage && ( - - Homepage - - {plugin.homepage} - - - - )} - - {/* Timestamps */} - - Metadata - -
- Created At - {formatDateString(plugin.created_at)} -
-
- Updated At - {formatDateString(plugin.updated_at)} -
- {plugin.created_by && ( -
- Created By - {plugin.created_by} -
- )} -
-
-
- ); -}; - -export default PluginInfoView; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx index 042f02251b4..e70548d6a96 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx @@ -17,15 +17,23 @@ vi.mock("@/utils/mcpTokenStore", () => ({ })); // Mutable holder so individual tests can simulate "Authorize & Fetch" having -// produced a token before submit. -const oauthHook = vi.hoisted(() => ({ tokenResponse: null as Record | null })); +// produced a token before submit, and inspect the reset wiring. +const oauthHook = vi.hoisted(() => ({ + tokenResponse: null as Record | null, + reset: vi.fn(), + onTokenReceived: null as ((token: Record | null) => void) | null, +})); vi.mock("@/hooks/useMcpOAuthFlow", () => ({ - useMcpOAuthFlow: () => ({ - startOAuthFlow: vi.fn(), - status: "idle", - error: null, - tokenResponse: oauthHook.tokenResponse, - }), + useMcpOAuthFlow: (opts: { onTokenReceived: (token: Record | null) => void }) => { + oauthHook.onTokenReceived = opts.onTokenReceived; + return { + startOAuthFlow: vi.fn(), + status: "idle", + error: null, + tokenResponse: oauthHook.tokenResponse, + reset: oauthHook.reset, + }; + }, })); vi.mock("./mcp_server_cost_config", () => ({ @@ -59,7 +67,9 @@ vi.mock("./mcp_tool_configuration", () => ({ })); vi.mock("./mcp_connection_status", () => ({ - default: () =>
, + default: ({ tools }: { tools?: any[] }) => ( +
+ ), })); vi.mock("./StdioConfiguration", () => ({ @@ -121,6 +131,7 @@ describe("CreateMCPServer", () => { beforeEach(() => { vi.clearAllMocks(); oauthHook.tokenResponse = null; + oauthHook.onTokenReceived = null; }); it("should render the modal with title when visible", () => { @@ -614,6 +625,100 @@ describe("CreateMCPServer", () => { expect(defaultProps.setModalVisible).toHaveBeenCalledWith(false); }); + + it("does not leak a previous server's OAuth token into the next add-server session", async () => { + const usedToken = (token: string) => + vi.mocked(networking.testMCPToolsListRequest).mock.calls.some((call) => call[2] === token); + + const { rerender } = render(); + + await selectAntOption("Transport Type", "Streamable HTTP"); + await waitFor(() => { + expect(screen.getByPlaceholderText("https://your-mcp-server.com")).toBeInTheDocument(); + }); + await selectAntOption("Authentication", "OAuth"); + await waitFor(() => { + expect(screen.getByText("OAuth Flow Type")).toBeInTheDocument(); + }); + + const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com"); + await act(async () => { + fireEvent.change(urlInput, { target: { value: "https://server-a.example.com/mcp" } }); + }); + + // Simulate "Authorize & Fetch Token" completing for server A. + await act(async () => { + oauthHook.onTokenReceived?.({ access_token: "stale-token-A", expires_in: 3600 }); + }); + + // Precondition: the freshly fetched token drives the tool preview for server A. + await waitFor(() => { + expect(usedToken("stale-token-A")).toBe(true); + }); + + // Parent hides the modal (Cancel / successful create both flip this prop). + rerender(); + + // The OAuth flow state (source of the "Token fetched" badge) is reset on close. + expect(oauthHook.reset).toHaveBeenCalled(); + + vi.mocked(networking.testMCPToolsListRequest).mockClear(); + + // Reopen for a brand-new server and enter a different URL without re-authorizing. + rerender(); + const reopenedUrlInput = screen.getByPlaceholderText("https://your-mcp-server.com"); + await act(async () => { + fireEvent.change(reopenedUrlInput, { target: { value: "https://server-b.example.com/mcp" } }); + }); + + // The previous server's token must never be replayed for the new session. + expect(usedToken("stale-token-A")).toBe(false); + }); + + it("clears the tool list and form fields when a parent dismisses the modal", async () => { + vi.mocked(networking.testMCPToolsListRequest).mockResolvedValue({ + tools: [{ name: "tool_a" }], + error: null, + }); + const toolCount = () => screen.getByTestId("mcp-connection-status").getAttribute("data-tool-count"); + + const { rerender } = render(); + + await selectAntOption("Transport Type", "Streamable HTTP"); + await waitFor(() => { + expect(screen.getByPlaceholderText("https://your-mcp-server.com")).toBeInTheDocument(); + }); + await selectAntOption("Authentication", "OAuth"); + await waitFor(() => { + expect(screen.getByText("OAuth Flow Type")).toBeInTheDocument(); + }); + + const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com"); + await act(async () => { + fireEvent.change(urlInput, { target: { value: "https://server-a.example.com/mcp" } }); + }); + await act(async () => { + oauthHook.onTokenReceived?.({ access_token: "stale-token-A", expires_in: 3600 }); + }); + + // Precondition: a tool list is shown for server A. + await waitFor(() => { + expect(toolCount()).toBe("1"); + }); + + // Parent dismisses the modal without routing through Cancel or create. + rerender(); + + // Stale tools are cleared even though neither handler ran. + await waitFor(() => { + expect(toolCount()).toBe("0"); + }); + + // Reopening starts clean: the URL the prior server left in the Ant form store is gone. + rerender(); + const reopenedUrlInput = screen.getByPlaceholderText("https://your-mcp-server.com") as HTMLInputElement; + expect(reopenedUrlInput.value).toBe(""); + }); }); describe("when stdio transport is selected", () => { diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx index 28c38c459aa..ddcc9f65d38 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx @@ -134,6 +134,7 @@ const CreateMCPServer: React.FC = ({ status: oauthStatus, error: oauthError, tokenResponse: oauthTokenResponse, + reset: resetOAuthFlow, } = useMcpOAuthFlow({ accessToken, getCredentials: () => form.getFieldValue("credentials"), @@ -554,12 +555,19 @@ const CreateMCPServer: React.FC = ({ } }, [formValues.server_name]); - // Clear formValues when modal closes to reset child components + // Clear form, tools, and OAuth state when the modal closes so a previous server's + // authorization, credentials, or tool list never bleed into the next "Add New MCP + // Server" session, including when a parent dismisses the modal without routing + // through handleCancel or handleCreate. React.useEffect(() => { if (!isModalVisible) { + form.resetFields(); setFormValues({}); + setOauthAccessToken(null); + clearTools(); + resetOAuthFlow(); } - }, [isModalVisible]); + }, [isModalVisible, form, clearTools, resetOAuthFlow]); const isAdmin = isAdminRole(userRole); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_columns.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_columns.tsx deleted file mode 100644 index bc13ed72a8e..00000000000 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_columns.tsx +++ /dev/null @@ -1,321 +0,0 @@ -import { useState } from "react"; -import { ColumnDef } from "@tanstack/react-table"; -import { MCPServer } from "./types"; -import { Icon } from "@tremor/react"; -import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline"; -import { getMaskedAndFullUrl } from "./utils"; -import { Tooltip } from "antd"; -import { CheckOutlined } from "@ant-design/icons"; - -const HealthStatusBadge: React.FC<{ - server: MCPServer; - isLoadingHealth?: boolean; - isRechecking?: boolean; - onRecheck?: (serverId: string) => void; -}> = ({ server, isLoadingHealth, isRechecking, onRecheck }) => { - const [isHovered, setIsHovered] = useState(false); - const status = server.status || "unknown"; - const lastCheck = server.last_health_check; - const error = server.health_check_error; - - if (isLoadingHealth || isRechecking) { - return ( - - - Checking - - ); - } - - const getStatusColor = (status: string) => { - switch (status) { - case "healthy": - return "text-green-700 bg-green-50 border border-green-200"; - case "unhealthy": - return "text-red-700 bg-red-50 border border-red-200"; - default: - return "text-gray-600 bg-gray-50 border border-gray-200"; - } - }; - - const getStatusIcon = (status: string) => { - switch (status) { - case "healthy": - return "✓"; - case "unhealthy": - return "✗"; - default: - return "?"; - } - }; - - const isClickable = !!onRecheck; - - const tooltipContent = ( -
-
Health Status: {status}
- {lastCheck &&
Last Check: {new Date(lastCheck).toLocaleString()}
} - {error && ( -
-
Error:
-
{error}
-
- )} - {!lastCheck && !error &&
No health check data available
} - {isClickable &&
Click to recheck
} -
- ); - - return ( - - setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - onClick={isClickable ? () => onRecheck(server.server_id) : undefined} - > - {isHovered && isClickable ? "↻" : getStatusIcon(status)} - {isHovered && isClickable ? "Recheck" : status.charAt(0).toUpperCase() + status.slice(1)} - - - ); -}; - -export const mcpServerColumns = ( - userRole: string, - onView: (serverId: string) => void, - onEdit: (serverId: string) => void, - onDelete: (serverId: string) => void, - isLoadingHealth?: boolean, - onByokConnect?: (server: MCPServer) => void, - onRecheckHealth?: (serverId: string) => void, - recheckingServerIds?: Set, -): ColumnDef[] => [ - { - accessorKey: "server_id", - header: "Server ID", - enableSorting: true, - cell: ({ row }) => ( - - ), - }, - { - accessorKey: "server_name", - header: "Name", - enableSorting: true, - cell: ({ row }) => { - const logoUrl = row.original.mcp_info?.logo_url; - const name = row.original.server_name; - return ( -
- {logoUrl ? ( - {`${name { - (e.target as HTMLImageElement).style.display = "none"; - }} - /> - ) : null} - {name} -
- ); - }, - }, - { - accessorKey: "alias", - header: "Alias", - enableSorting: true, - }, - { - id: "url", - header: "URL", - cell: ({ row }) => { - const url = row.original.url; - if (!url) { - return ; - } - const { maskedUrl } = getMaskedAndFullUrl(url); - return {maskedUrl}; - }, - }, - { - accessorKey: "transport", - header: "Transport", - enableSorting: true, - cell: ({ row }) => { - const transport = row.original.transport || "http"; - const specPath = row.original.spec_path; - const displayTransport = specPath && transport !== "stdio" ? "OPENAPI" : transport; - const label = displayTransport.toUpperCase(); - return ( - - {label} - - ); - }, - }, - { - accessorKey: "auth_type", - header: "Auth Type", - enableSorting: true, - cell: ({ getValue }) => { - const authType = (getValue() as string) || "none"; - return ( - - {authType} - - ); - }, - }, - { - id: "health_status", - header: "Health Status", - cell: ({ row }) => ( - - ), - }, - { - id: "mcp_access_groups", - header: "Access Groups", - cell: ({ row }) => { - const groups = row.original.mcp_access_groups; - if (Array.isArray(groups) && groups.length > 0) { - if (typeof groups[0] === "string") { - const joined = groups.join(", "); - return ( - -
- - {groups[0]} - - {groups.length > 1 && +{groups.length - 1}} -
-
- ); - } - } - return ; - }, - }, - { - id: "available_on_public_internet", - header: "Network Access", - cell: ({ row }) => { - const isPublic = row.original.available_on_public_internet; - return isPublic ? ( - - - Public - - ) : ( - - - Internal - - ); - }, - }, - { - header: "Created", - accessorKey: "created_at", - enableSorting: true, - sortingFn: "datetime", - cell: ({ row }) => { - const server = row.original; - if (!server.created_at) return ; - const date = new Date(server.created_at); - return ( - - {date.toLocaleDateString()} - - ); - }, - }, - { - header: "Updated", - accessorKey: "updated_at", - enableSorting: true, - sortingFn: "datetime", - cell: ({ row }) => { - const server = row.original; - if (!server.updated_at) return ; - const date = new Date(server.updated_at); - return ( - - {date.toLocaleDateString()} - - ); - }, - }, - { - id: "byok_credential", - header: "Credential", - cell: ({ row }) => { - const server = row.original; - if (!server.is_byok) { - return ; - } - if (server.has_user_credential) { - return ( -
- - Connected - - {onByokConnect && ( - - )} -
- ); - } - return onByokConnect ? ( - - ) : null; - }, - }, - { - id: "actions", - header: "Actions", - cell: ({ row }) => ( -
- - - - - - -
- ), - }, -]; diff --git a/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.test.tsx b/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.test.tsx new file mode 100644 index 00000000000..d8dce3c29e9 --- /dev/null +++ b/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.test.tsx @@ -0,0 +1,117 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as networking from "@/components/networking"; +import { setSecureItem } from "@/utils/secureStorage"; +import { useMcpOAuthFlow } from "./useMcpOAuthFlow"; + +vi.mock("@/components/networking", () => ({ + exchangeMcpOAuthToken: vi.fn(), + cacheTemporaryMcpServer: vi.fn(), + registerMcpOAuthClient: vi.fn(), + buildMcpOAuthAuthorizeUrl: vi.fn(), + getProxyBaseUrl: vi.fn(() => ""), + serverRootPath: "", +})); + +vi.mock("@/components/molecules/notifications_manager", () => ({ + default: { success: vi.fn(), error: vi.fn() }, +})); + +const FLOW_STATE_KEY = "litellm-mcp-oauth-flow-state"; +const RESULT_KEY = "litellm-mcp-oauth-result"; + +/** Seed the redirect result (the code returned by the IdP callback). */ +function seedResult(code: string) { + setSecureItem(RESULT_KEY, JSON.stringify({ state: "state-1", code })); +} + +/** Seed the flow state stored before the redirect. */ +function seedFlowState() { + setSecureItem( + FLOW_STATE_KEY, + JSON.stringify({ + state: "state-1", + codeVerifier: "verifier-1", + serverId: "server-1", + clientId: "client-1", + redirectUri: "https://app.example.com/ui/mcp/oauth/callback", + flowSource: "create", + }), + ); +} + +/** Seed storage so the hook's on-mount resume flow exchanges a code for a token. */ +function seedCompletedRedirect() { + seedResult("code-1"); + seedFlowState(); +} + +function renderFlow(onTokenReceived = vi.fn()) { + return renderHook( + ({ onTokenReceived: cb }: { onTokenReceived: (t: any) => void }) => + useMcpOAuthFlow({ + accessToken: "admin-token", + getCredentials: () => ({}), + getTemporaryPayload: () => ({ url: "https://server-1.example.com/mcp", transport: "http" }), + onTokenReceived: cb, + flowSource: "create", + }), + { initialProps: { onTokenReceived } }, + ); +} + +describe("useMcpOAuthFlow reset", () => { + beforeEach(() => { + vi.clearAllMocks(); + window.sessionStorage.clear(); + window.localStorage.clear(); + }); + + it("clears a successfully fetched token so it cannot leak into the next session", async () => { + const token = { access_token: "tok-123", expires_in: 3600 }; + vi.mocked(networking.exchangeMcpOAuthToken).mockResolvedValue(token); + seedCompletedRedirect(); + + const onTokenReceived = vi.fn(); + const { result } = renderFlow(onTokenReceived); + + await waitFor(() => expect(result.current.status).toBe("success")); + expect(result.current.tokenResponse).toEqual(token); + expect(onTokenReceived).toHaveBeenCalledWith(token); + + act(() => { + result.current.reset(); + }); + + expect(result.current.status).toBe("idle"); + expect(result.current.tokenResponse).toBeNull(); + expect(result.current.error).toBeNull(); + }); + + it("clears the in-flight guard so a callback after a mid-exchange close is not swallowed", async () => { + // First exchange hangs, mimicking the modal being closed while the token + // endpoint is still in flight. processingRef is left true at that point. + vi.mocked(networking.exchangeMcpOAuthToken).mockReturnValueOnce(new Promise(() => {})); + seedFlowState(); + seedResult("code-1"); + + const onTokenReceived1 = vi.fn(); + const { result, rerender } = renderFlow(onTokenReceived1); + + await waitFor(() => expect(result.current.status).toBe("exchanging")); + + act(() => { + result.current.reset(); + }); + + // The reopened modal receives a fresh callback; it must be processed, not + // dropped by a stale in-flight guard. + const token = { access_token: "tok-2" }; + vi.mocked(networking.exchangeMcpOAuthToken).mockResolvedValueOnce(token); + seedResult("code-2"); + const onTokenReceived2 = vi.fn(); + rerender({ onTokenReceived: onTokenReceived2 }); + + await waitFor(() => expect(onTokenReceived2).toHaveBeenCalledWith(token)); + }); +}); diff --git a/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx b/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx index c4561a9f53a..ee253577d9d 100644 --- a/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx +++ b/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx @@ -40,6 +40,7 @@ interface UseMcpOAuthFlowResult { status: McpOAuthStatus; error: string | null; tokenResponse: Record | null; + reset: () => void; } export const useMcpOAuthFlow = ({ @@ -336,10 +337,18 @@ export const useMcpOAuthFlow = ({ resumeOAuthFlow(); }, [resumeOAuthFlow]); + const reset = useCallback(() => { + setStatus("idle"); + setError(null); + setTokenResponse(null); + processingRef.current = false; + }, []); + return { startOAuthFlow, status, error, tokenResponse, + reset, }; }; diff --git a/ui/litellm-dashboard/src/hooks/useTestMCPConnection.tsx b/ui/litellm-dashboard/src/hooks/useTestMCPConnection.tsx index 3dcd6be888d..b160c90224c 100644 --- a/ui/litellm-dashboard/src/hooks/useTestMCPConnection.tsx +++ b/ui/litellm-dashboard/src/hooks/useTestMCPConnection.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { testMCPToolsListRequest } from "../components/networking"; import { AUTH_TYPE, OAUTH_FLOW, TRANSPORT } from "@/components/mcp_tools/types"; @@ -177,12 +177,12 @@ export const useTestMCPConnection = ({ } }; - const clearTools = () => { + const clearTools = useCallback(() => { setTools([]); setToolsError(null); setToolsErrorStackTrace(null); setHasShownSuccessMessage(false); - }; + }, []); // Auto-fetch tools when form values change and required fields are available useEffect(() => { diff --git a/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx b/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx index 2e1025e18bc..2ebde4f295d 100644 --- a/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx +++ b/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx @@ -72,6 +72,8 @@ vi.mock("@/components/networking", () => { return { // Called on mount; we don't care about its contents, only that it resolves getUiConfig: vi.fn().mockResolvedValue({}), + // Fetched by useUISettings(); resolve with empty settings so nudges stay default-on + getUiSettings: vi.fn().mockResolvedValue({ values: {}, field_schema: {} }), // Used to build the redirect URL proxyBaseUrl: "https://example.com", // Called when decoding a valid token @@ -146,17 +148,22 @@ vi.mock("@/lib/cva.config", () => ({ cx: (...args: string[]) => args.join(" "), })); +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import CreateKeyPage from "@/app/page"; import { AuthProvider } from "@/contexts/AuthContext"; // The page consumes auth state via useAuth(). Wrap it so the hook resolves // against a real provider — the provider's effects (cookie read, JWT decode, -// redirect-on-expired) are what these tests exercise. +// redirect-on-expired) are what these tests exercise. The QueryClientProvider +// mirrors what layout.tsx supplies in production for hooks like useUISettings. function PageUnderTest() { + const [queryClient] = React.useState(() => new QueryClient({ defaultOptions: { queries: { retry: false } } })); return ( - - - + + + + + ); }