diff --git a/litellm/llms/base_llm/chat/transformation.py b/litellm/llms/base_llm/chat/transformation.py index 0d6d942e686..d147063df73 100644 --- a/litellm/llms/base_llm/chat/transformation.py +++ b/litellm/llms/base_llm/chat/transformation.py @@ -5,7 +5,7 @@ Common base config for all LLM providers import types from abc import ABC, abstractmethod from collections.abc import AsyncIterator, Iterator -from typing import TYPE_CHECKING, Any, Final, Union, cast +from typing import TYPE_CHECKING, Any, Final, Union import httpx from pydantic import BaseModel @@ -90,9 +90,9 @@ class BaseConfig(ABC): return type_to_response_format_param(response_format=response_format) def is_thinking_enabled(self, non_default_params: dict) -> bool: - return (non_default_params.get("thinking") or {}).get("type") == "enabled" or non_default_params.get( - "reasoning_effort" - ) is not None + thinking: Final = non_default_params.get("thinking") + thinking_type: Final = thinking.get("type") if isinstance(thinking, dict) else None + return thinking is True or thinking_type == "enabled" or non_default_params.get("reasoning_effort") is not None def is_max_tokens_in_request(self, non_default_params: dict) -> bool: """ @@ -112,7 +112,10 @@ class BaseConfig(ABC): if is_thinking_enabled and ( "max_tokens" not in non_default_params and "max_completion_tokens" not in non_default_params ): - thinking_token_budget: Final = cast(dict, optional_params["thinking"]).get("budget_tokens", None) + thinking_value: Final = optional_params.get("thinking") + thinking_token_budget: Final = ( + thinking_value.get("budget_tokens") if isinstance(thinking_value, dict) else None + ) if thinking_token_budget is not None: optional_params["max_tokens"] = thinking_token_budget + DEFAULT_MAX_TOKENS diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index fd07999395b..cee89f42c2d 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -1090,7 +1090,10 @@ class AmazonConverseConfig(BaseConfig): is_thinking_enabled: Final = self.is_thinking_enabled(optional_params) is_max_tokens_in_request: Final = self.is_max_tokens_in_request(non_default_params) if is_thinking_enabled and not is_max_tokens_in_request: - thinking_token_budget: Final = cast(dict, optional_params["thinking"]).get("budget_tokens", None) + thinking_value: Final = optional_params.get("thinking") + thinking_token_budget: Final = ( + thinking_value.get("budget_tokens") if isinstance(thinking_value, dict) else None + ) if thinking_token_budget is not None: optional_params["maxTokens"] = thinking_token_budget + DEFAULT_MAX_TOKENS diff --git a/litellm/llms/deepseek/chat/transformation.py b/litellm/llms/deepseek/chat/transformation.py index 24da5b79261..566c960333a 100644 --- a/litellm/llms/deepseek/chat/transformation.py +++ b/litellm/llms/deepseek/chat/transformation.py @@ -131,9 +131,11 @@ class DeepSeekChatConfig(OpenAIGPTConfig): - model supports reasoning (capability check) - user explicitly passed thinking={"type": "enabled"} (opt-in check) """ + thinking: Final = optional_params.get("thinking") return ( supports_reasoning(model=model, custom_llm_provider="deepseek") - and (optional_params.get("thinking") or {}).get("type") == "enabled" + and isinstance(thinking, dict) + and thinking.get("type") == "enabled" ) @staticmethod diff --git a/litellm/main.py b/litellm/main.py index cc27da830d8..f0b20eba9b6 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -5007,7 +5007,6 @@ def completion( tool_choice = validate_chat_completion_tool_choice(tool_choice=tool_choice) # validate optional params stop = validate_openai_optional_params(stop=stop) - # normalize camelCase thinking keys (e.g. budgetTokens -> budget_tokens) thinking = validate_and_fix_thinking_param(thinking=thinking) ######### unpacking kwargs ##################### diff --git a/litellm/proxy/anthropic_endpoints/endpoints.py b/litellm/proxy/anthropic_endpoints/endpoints.py index a48ef0f08bb..f742965ade2 100644 --- a/litellm/proxy/anthropic_endpoints/endpoints.py +++ b/litellm/proxy/anthropic_endpoints/endpoints.py @@ -179,7 +179,7 @@ async def anthropic_response( await proxy_logging_obj.post_call_failure_hook( user_api_key_dict=user_api_key_dict, original_exception=e, - request_data=data, + request_data=base_llm_response_processor.data, ) body: Final = AnthropicExceptionMapping.transform_to_anthropic_error( status_code=e.status_code, @@ -189,7 +189,7 @@ async def anthropic_response( return JSONResponse(status_code=e.status_code, content=body) except Exception as e: await proxy_logging_obj.post_call_failure_hook( - user_api_key_dict=user_api_key_dict, original_exception=e, request_data=data + user_api_key_dict=user_api_key_dict, original_exception=e, request_data=base_llm_response_processor.data ) verbose_proxy_logger.exception("litellm.proxy.proxy_server.anthropic_response(): Exception occured - %s", e) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 56036713fa9..fdce8c868d1 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -10254,11 +10254,9 @@ async def embeddings( """ global proxy_logging_obj - data: Any = {} + data: Final = await _read_request_body(request=request) + base_llm_response_processor: Final = ProxyBaseLLMRequestProcessing(data=data) try: - # Use shared request body reading helper (same as chat/completions) - data = await _read_request_body(request=request) - ### HANDLE TOKEN ARRAY INPUT DECODING ### # This must happen BEFORE base_process_llm_request() since it modifies the input router_model_names: Final = llm_router.model_names if llm_router is not None else [] @@ -10302,10 +10300,6 @@ async def embeddings( if hasattr(user_api_key_dict, "agent_id") and user_api_key_dict.agent_id is not None: data["metadata"]["agent_id"] = user_api_key_dict.agent_id - # Use unified request processor (same as chat/completions and responses) - base_llm_response_processor = ProxyBaseLLMRequestProcessing(data=data) - - # Process the request with all optimizations (shared sessions, network tuning, etc.) response: Final = await base_llm_response_processor.base_process_llm_request( request=request, fastapi_response=fastapi_response, @@ -10327,8 +10321,6 @@ async def embeddings( return response except Exception as e: - # Use unified error handler - base_llm_response_processor = ProxyBaseLLMRequestProcessing(data=data) raise await base_llm_response_processor._handle_llm_api_exception( e=e, user_api_key_dict=user_api_key_dict, diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index 2ad7180bd5f..a743526e975 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -40,7 +40,7 @@ from litellm.proxy._types import ( from litellm.proxy.spend_tracking.spend_log_error_logger import spend_log_error from litellm.types.guardrails import GuardrailEventHooks from litellm.types.proxy.model_listing import ModelInfoResponse -from litellm.types.utils import CallTypes, CallTypesLiteral, ModelInfo +from litellm.types.utils import CallTypes, CallTypesLiteral, ModelInfo, Usage try: from litellm_enterprise.enterprise_callbacks.send_emails.base_email import ( @@ -403,6 +403,120 @@ def _exception_changes_request_flow(exc: BaseException) -> bool: return isinstance(exc, (SensitiveDataRouteException, ModifyResponseException)) +def _prompt_block_text(block: object) -> str: + if isinstance(block, str): + return block + if not isinstance(block, dict): + return "" + block_text: Final = block.get("text") + return block_text if isinstance(block_text, str) else "" + + +def _system_prompt_text(system_input: object) -> str: + if isinstance(system_input, str): + return system_input + if not isinstance(system_input, list): + return "" + return "".join(_prompt_block_text(block) for block in system_input) + + +def _count_request_input_tokens(model: str, request_input: object, system_input: object) -> int: + system_text: Final = _system_prompt_text(system_input) + system_tokens: Final = litellm.token_counter(model=model, text=system_text) if system_text else 0 + if isinstance(request_input, str): + return system_tokens + litellm.token_counter(model=model, text=request_input) + if not isinstance(request_input, list) or not request_input: + return system_tokens + text_entries: Final = tuple(entry for entry in request_input if isinstance(entry, str)) + if len(text_entries) == len(request_input): + return system_tokens + litellm.token_counter(model=model, text="".join(text_entries)) + return system_tokens + litellm.token_counter( + model=model, messages=request_input, use_default_image_token_count=True + ) + + +def _estimate_dispatched_failure_usage(model: str, request_input: object, system_input: object) -> Usage | None: + """A request that failed after dispatch consumed provider-billed input + tokens, but no provider usage ever came back. Estimate the input side with + the same tokenizer fallback interrupted streams use, so the spend log's + failure row records what was sent instead of zero.""" + try: + input_tokens: Final = _count_request_input_tokens( + model=model, request_input=request_input, system_input=system_input + ) + except Exception: + return None + if input_tokens <= 0: + return None + return Usage(prompt_tokens=input_tokens, completion_tokens=0, total_tokens=input_tokens) + + +_INPUT_ESTIMABLE_CALL_TYPES: Final = frozenset( + call_type.value + for call_type in ( + CallTypes.completion, + CallTypes.acompletion, + CallTypes.text_completion, + CallTypes.atext_completion, + CallTypes.anthropic_messages, + CallTypes.aanthropic_messages, + CallTypes.responses, + CallTypes.aresponses, + CallTypes.embedding, + CallTypes.aembedding, + CallTypes.moderation, + CallTypes.amoderation, + CallTypes.image_generation, + CallTypes.aimage_generation, + CallTypes.speech, + CallTypes.aspeech, + CallTypes.rerank, + CallTypes.arerank, + CallTypes.generate_content, + CallTypes.agenerate_content, + CallTypes.generate_content_stream, + CallTypes.agenerate_content_stream, + ) +) + + +def _failure_usage_to_lift( + model_call_details: Mapping[str, object], + request_body: Mapping[str, object], + dispatched: bool, +) -> tuple[object, object] | None: + """A stream that broke mid-flight still billed the provider for the chunks + already delivered; the streaming handler stashes that recovered usage and + cost in model_call_details, so prefer it. Otherwise a request that was + dispatched to a provider and failed without upstream usage gets an + estimated input-side Usage with zero cost. The raw request body backfills + the system prompt when the SDK bridges an endpoint (e.g. /v1/messages on a + chat-completions provider) without filling optional_params. Returns the + (combined_usage_object, response_cost) pair to lift, or None.""" + recovered_usage: Final = model_call_details.get("combined_usage_object") + if recovered_usage is not None: + return recovered_usage, model_call_details.get("response_cost") + if not dispatched or model_call_details.get(LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL): + return None + if str(model_call_details.get("call_type")) not in _INPUT_ESTIMABLE_CALL_TYPES: + return None + optional_params: Final = model_call_details.get("optional_params") + dispatched_system: Final = ( + (optional_params.get("system") or optional_params.get("instructions")) + if isinstance(optional_params, dict) + else None + ) + system_input: Final = dispatched_system or request_body.get("system") or request_body.get("instructions") + estimated_usage: Final = _estimate_dispatched_failure_usage( + model=str(model_call_details.get("model") or ""), + request_input=model_call_details.get("messages"), + system_input=system_input, + ) + if estimated_usage is None: + return None + return estimated_usage, 0.0 + + @dataclass(frozen=True) class _CallbackCapabilities: """Cached per-hook capability flags derived from ``litellm.callbacks``. @@ -2190,15 +2304,19 @@ class ProxyLogging: if _first_handoff is not None: request_data["first_api_call_start_time"] = _first_handoff - # A stream that broke mid-flight still billed the provider for the - # chunks already delivered; the streaming handler stashes that - # recovered usage and cost here. Lift them onto request_data so the + # Lift recovered partial-stream usage, or an estimated input-side + # usage for a dispatched failure, onto request_data so the # failure-path spend callbacks (which run after the logging object - # is popped) record the real partial spend instead of zero. - _recovered_usage: Final = _model_call_details.get("combined_usage_object") - if _recovered_usage is not None: - request_data["combined_usage_object"] = _recovered_usage - request_data["response_cost"] = _model_call_details.get("response_cost") + # is popped) record real token counts instead of zero. + _usage_to_lift: Final = _failure_usage_to_lift( + model_call_details=_model_call_details, + request_body=request_data, + dispatched=_first_handoff is not None, + ) + if _usage_to_lift is not None: + _lifted_usage, _lifted_cost = _usage_to_lift + request_data["combined_usage_object"] = _lifted_usage + request_data["response_cost"] = _lifted_cost # Remove before callbacks iterate — not serialisable request_data.pop("litellm_logging_obj", None) diff --git a/litellm/utils.py b/litellm/utils.py index 1c880ee9521..a7b70c4129a 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -65,6 +65,7 @@ from litellm.constants import ( DEFAULT_EMBEDDING_PARAM_VALUES, DEFAULT_MAX_LRU_CACHE_SIZE, DEFAULT_MINIMUM_PROMPT_CACHE_TOKEN_COUNT, + DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET, DEFAULT_TRIM_RATIO, FUNCTION_DEFINITION_TOKEN_COUNT, INITIAL_RETRY_DELAY, @@ -7638,12 +7639,20 @@ def validate_and_fix_openai_tools(tools: list | None) -> list[dict] | None: def validate_and_fix_thinking_param( - thinking: AnthropicThinkingParam | None, + thinking: AnthropicThinkingParam | bool | None, ) -> AnthropicThinkingParam | None: """ - Normalizes camelCase keys in the thinking param to snake_case. + Coerces bool thinking values (True becomes enabled with the default medium budget, False becomes None) + and normalizes camelCase keys in the thinking param to snake_case. Handles clients that send budgetTokens instead of budget_tokens. """ + if thinking is True: + return cast( + "AnthropicThinkingParam", + {"type": "enabled", "budget_tokens": DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET}, + ) + if thinking is False: + return None if thinking is None or not isinstance(thinking, dict): return thinking normalized: Final = dict(thinking) 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 2509f6480d5..298360789eb 100644 --- a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py @@ -6043,3 +6043,12 @@ def test_streaming_usage_chunk_is_transformed(): assert chunk.usage.prompt_tokens == 11 assert chunk.usage.completion_tokens == 4 assert chunk.usage.total_tokens == 15 + + +def test_update_optional_params_with_thinking_tokens_bool_thinking_does_not_crash(): + config = AmazonConverseConfig() + optional_params = {"thinking": True} + config.update_optional_params_with_thinking_tokens( + non_default_params={"thinking": True}, optional_params=optional_params + ) + assert "maxTokens" not in optional_params diff --git a/tests/test_litellm/llms/deepseek/chat/test_deepseek_chat_transformation.py b/tests/test_litellm/llms/deepseek/chat/test_deepseek_chat_transformation.py index ec51e5d303d..d5783e3567f 100644 --- a/tests/test_litellm/llms/deepseek/chat/test_deepseek_chat_transformation.py +++ b/tests/test_litellm/llms/deepseek/chat/test_deepseek_chat_transformation.py @@ -101,3 +101,8 @@ async def test_async_transform_request_strips_unsupported_tools_from_body(): assert [tool["type"] for tool in body["tools"]] == ["function"] assert body["tools"][0]["function"]["name"] == "shell" + + +def test_thinking_mode_active_bool_thinking_returns_false_without_crashing(): + config = DeepSeekChatConfig() + assert config._thinking_mode_active(model="deepseek-reasoner", optional_params={"thinking": True}) is False diff --git a/tests/test_litellm/proxy/anthropic_endpoints/test_endpoints.py b/tests/test_litellm/proxy/anthropic_endpoints/test_endpoints.py index 0a427df0cb7..9a90daeccb7 100644 --- a/tests/test_litellm/proxy/anthropic_endpoints/test_endpoints.py +++ b/tests/test_litellm/proxy/anthropic_endpoints/test_endpoints.py @@ -164,6 +164,41 @@ class TestProxyExceptionPassthrough: mock_logging.post_call_failure_hook.assert_awaited_once() +class TestFailureHookRequestData: + @pytest.mark.asyncio + async def test_failure_hook_gets_post_setup_data_with_logging_obj(self): + """Request setup replaces the processor's data dict (adding the logging + object the failure hook needs to lift token usage from); the exception + handler must pass that replaced dict, not the raw request body dict.""" + import litellm.proxy.anthropic_endpoints.endpoints as ep + import litellm.proxy.proxy_server as proxy_server + from litellm.proxy._types import ProxyException, UserAPIKeyAuth + + captured = {} + + async def fake_process(self, **kwargs): + self.data = {**self.data, "litellm_logging_obj": "logging-obj-sentinel"} + captured["processor_data"] = self.data + raise RuntimeError("provider timeout") + + with ( + patch.object(ep, "_read_request_body", new=AsyncMock(return_value={"model": "claude-sonnet"})), + patch.object(ep.ProxyBaseLLMRequestProcessing, "base_process_llm_request", new=fake_process), + patch.object(proxy_server, "proxy_logging_obj") as mock_logging, + ): + mock_logging.post_call_failure_hook = AsyncMock() + with pytest.raises(ProxyException): + await ep.anthropic_response( + fastapi_response=MagicMock(), + request=MagicMock(), + user_api_key_dict=UserAPIKeyAuth(), + ) + + hook_request_data = mock_logging.post_call_failure_hook.await_args.kwargs["request_data"] + assert hook_request_data is captured["processor_data"] + assert hook_request_data["litellm_logging_obj"] == "logging-obj-sentinel" + + class TestEventLoggingBatchEndpoint: """Test the stubbed event logging batch endpoint""" diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 5545ee92e84..7fdfbea843f 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -11098,3 +11098,46 @@ async def test_moderations_reraises_proxy_exception_unwrapped(): assert exc_info.value.code == "400" assert exc_info.value.param == "metadata" mock_logging.post_call_failure_hook.assert_awaited_once() + + +class TestEmbeddingsFailureHookRequestData: + @pytest.mark.asyncio + async def test_failure_hook_gets_post_setup_data_with_logging_obj(self): + """Request setup replaces the processor's data dict (adding the logging + object the failure hook needs to lift token usage from); the embeddings + exception handler must pass that replaced dict, not the raw request body + dict it was rebuilt from.""" + from litellm.proxy._types import ProxyException + + captured = {} + logging_obj_sentinel = MagicMock() + + async def fake_process(self, **kwargs): + self.data = {**self.data, "litellm_logging_obj": logging_obj_sentinel} + captured["processor_data"] = self.data + raise RuntimeError("provider timeout") + + with ( + patch.object( + proxy_server_module, + "_read_request_body", + new=AsyncMock(return_value={"model": "my-embed", "input": "hello"}), + ), + patch.object( + proxy_server_module.ProxyBaseLLMRequestProcessing, + "base_process_llm_request", + new=fake_process, + ), + patch.object(proxy_server_module, "proxy_logging_obj") as mock_logging, + ): + mock_logging.post_call_failure_hook = AsyncMock(return_value=None) + with pytest.raises(ProxyException): + await proxy_server_module.embeddings( + request=MagicMock(), + fastapi_response=MagicMock(), + user_api_key_dict=UserAPIKeyAuth(), + ) + + hook_request_data = mock_logging.post_call_failure_hook.await_args.kwargs["request_data"] + assert hook_request_data is captured["processor_data"] + assert hook_request_data["litellm_logging_obj"] is logging_obj_sentinel diff --git a/tests/test_litellm/proxy/test_proxy_utils.py b/tests/test_litellm/proxy/test_proxy_utils.py index 1504c3c3103..d6cf0e30139 100644 --- a/tests/test_litellm/proxy/test_proxy_utils.py +++ b/tests/test_litellm/proxy/test_proxy_utils.py @@ -478,6 +478,307 @@ class TestPostCallFailureHookLiftsRecoveredPartialSpend: assert "response_cost" not in request_data +class TestPostCallFailureHookEstimatesDispatchedInputTokens: + """A non-stream request that failed after dispatch (timeout, provider + error) consumed provider-billed input tokens but recovered no usage. + post_call_failure_hook must estimate the input side onto request_data so + the spend log's failure row records what was sent instead of zero, while + never charging spend for the failure (LIT-5690). + """ + + async def _run(self, request_data): + from unittest.mock import AsyncMock, patch + + from litellm.proxy._types import UserAPIKeyAuth + + proxy_logging_obj = ProxyLogging(user_api_key_cache=DualCache()) + proxy_logging_obj.alert_types = [] + with patch.object(proxy_logging_obj, "update_request_status", new=AsyncMock()): + await proxy_logging_obj.post_call_failure_hook( + request_data=request_data, + original_exception=Exception("boom"), + user_api_key_dict=UserAPIKeyAuth(), + ) + + def _logging_obj(self, model_call_details): + logging_obj = MagicMock() + logging_obj.model_call_details = model_call_details + return logging_obj + + @pytest.mark.asyncio + async def test_dispatched_failure_estimates_input_tokens_with_zero_cost(self): + from datetime import datetime + + from litellm.types.utils import Usage + + request_data = { + "litellm_logging_obj": self._logging_obj( + { + "first_api_call_start_time": datetime.now(), + "model": "gpt-3.5-turbo", + "messages": [{"role": "user", "content": "count these input tokens please"}], + "call_type": "acompletion", + } + ), + "metadata": {}, + "response_cost": 123.0, + } + await self._run(request_data) + + estimated = request_data["combined_usage_object"] + assert isinstance(estimated, Usage) + assert estimated.prompt_tokens > 0 + assert estimated.completion_tokens == 0 + assert estimated.total_tokens == estimated.prompt_tokens + assert request_data["response_cost"] == 0.0 + + @pytest.mark.asyncio + async def test_failure_before_dispatch_stays_zero(self): + request_data = { + "litellm_logging_obj": self._logging_obj( + { + "model": "gpt-3.5-turbo", + "messages": [{"role": "user", "content": "never dispatched"}], + } + ), + "metadata": {}, + } + await self._run(request_data) + + assert "combined_usage_object" not in request_data + assert "response_cost" not in request_data + + @pytest.mark.asyncio + async def test_proxy_only_error_never_dispatched_stays_zero(self): + from datetime import datetime + + from litellm.constants import LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL + + request_data = { + "litellm_logging_obj": self._logging_obj( + { + "first_api_call_start_time": datetime.now(), + "model": "no-such-model", + "messages": [{"role": "user", "content": "hi"}], + LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL: True, + } + ), + "metadata": {}, + } + await self._run(request_data) + + assert "combined_usage_object" not in request_data + assert "response_cost" not in request_data + + @pytest.mark.asyncio + async def test_recovered_partial_usage_wins_over_estimate(self): + from datetime import datetime + + from litellm.types.utils import Usage + + recovered_usage = Usage(prompt_tokens=30, completion_tokens=7, total_tokens=37) + request_data = { + "litellm_logging_obj": self._logging_obj( + { + "first_api_call_start_time": datetime.now(), + "model": "gpt-3.5-turbo", + "messages": [{"role": "user", "content": "mid-stream failure"}], + "call_type": "acompletion", + "combined_usage_object": recovered_usage, + "response_cost": 3.5e-05, + } + ), + "metadata": {}, + } + await self._run(request_data) + + assert request_data["combined_usage_object"] is recovered_usage + assert request_data["response_cost"] == 3.5e-05 + + @pytest.mark.asyncio + async def test_dispatched_failure_with_text_completion_prompt(self): + from datetime import datetime + + from litellm.types.utils import Usage + + request_data = { + "litellm_logging_obj": self._logging_obj( + { + "first_api_call_start_time": datetime.now(), + "model": "gpt-3.5-turbo", + "messages": "a plain text-completion prompt string", + "call_type": "atext_completion", + } + ), + "metadata": {}, + } + await self._run(request_data) + + estimated = request_data["combined_usage_object"] + assert isinstance(estimated, Usage) + assert estimated.prompt_tokens > 0 + assert estimated.completion_tokens == 0 + + def _dispatched_request_data(self, messages, optional_params, call_type="acompletion"): + from datetime import datetime + + return { + "litellm_logging_obj": self._logging_obj( + { + "first_api_call_start_time": datetime.now(), + "model": "gpt-3.5-turbo", + "messages": messages, + "optional_params": optional_params, + "call_type": call_type, + } + ), + "metadata": {}, + } + + @pytest.mark.asyncio + async def test_image_message_estimated_without_fetching_image(self): + import litellm as litellm_module + from litellm.types.utils import Usage + + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "describe this image"}, + { + "type": "image_url", + "image_url": {"url": "http://127.0.0.1:1/unreachable.png", "detail": "high"}, + }, + ], + } + ] + request_data = self._dispatched_request_data(messages, {}) + await self._run(request_data) + + estimated = request_data["combined_usage_object"] + assert isinstance(estimated, Usage) + expected = litellm_module.token_counter( + model="gpt-3.5-turbo", messages=messages, use_default_image_token_count=True + ) + assert estimated.prompt_tokens == expected + assert estimated.prompt_tokens > 0 + + @pytest.mark.asyncio + async def test_embedding_string_list_input_counted_in_estimate(self): + import litellm as litellm_module + from litellm.types.utils import Usage + + embedding_input = ["first embedding text", "second embedding text"] + request_data = self._dispatched_request_data(embedding_input, {}, call_type="aembedding") + await self._run(request_data) + + estimated = request_data["combined_usage_object"] + assert isinstance(estimated, Usage) + expected = litellm_module.token_counter(model="gpt-3.5-turbo", text="".join(embedding_input)) + assert estimated.prompt_tokens == expected + + @pytest.mark.asyncio + async def test_transcription_checksum_not_estimated(self): + request_data = self._dispatched_request_data("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", {}, call_type="atranscription") + await self._run(request_data) + + assert "combined_usage_object" not in request_data + assert "response_cost" not in request_data + + @pytest.mark.asyncio + async def test_anthropic_system_prompt_counted_in_estimate(self): + import litellm as litellm_module + from litellm.types.utils import Usage + + system_prompt = "You are a verbose historian who narrates every fact in exhaustive detail." + messages = [{"role": "user", "content": "write a short essay"}] + request_data = self._dispatched_request_data(messages, {"system": system_prompt, "max_tokens": 100}) + await self._run(request_data) + + estimated = request_data["combined_usage_object"] + assert isinstance(estimated, Usage) + expected = litellm_module.token_counter(model="gpt-3.5-turbo", messages=messages) + litellm_module.token_counter( + model="gpt-3.5-turbo", text=system_prompt + ) + assert estimated.prompt_tokens == expected + + @pytest.mark.asyncio + async def test_anthropic_system_text_blocks_counted_in_estimate(self): + import litellm as litellm_module + from litellm.types.utils import Usage + + system_blocks = [ + {"type": "text", "text": "part one of the system prompt. "}, + {"type": "text", "text": "part two of the system prompt."}, + ] + messages = [{"role": "user", "content": "write a short essay"}] + request_data = self._dispatched_request_data(messages, {"system": system_blocks}) + await self._run(request_data) + + estimated = request_data["combined_usage_object"] + assert isinstance(estimated, Usage) + expected = litellm_module.token_counter(model="gpt-3.5-turbo", messages=messages) + litellm_module.token_counter( + model="gpt-3.5-turbo", text="part one of the system prompt. part two of the system prompt." + ) + assert estimated.prompt_tokens == expected + + @pytest.mark.asyncio + async def test_responses_instructions_counted_in_estimate(self): + import litellm as litellm_module + from litellm.types.utils import Usage + + instructions = "Answer every question as a meticulous archivist." + request_data = self._dispatched_request_data("summarize the archive", {"instructions": instructions}) + await self._run(request_data) + + estimated = request_data["combined_usage_object"] + assert isinstance(estimated, Usage) + expected = litellm_module.token_counter( + model="gpt-3.5-turbo", text="summarize the archive" + ) + litellm_module.token_counter(model="gpt-3.5-turbo", text=instructions) + assert estimated.prompt_tokens == expected + + @pytest.mark.asyncio + async def test_request_body_system_counted_when_optional_params_empty(self): + import litellm as litellm_module + from litellm.types.utils import Usage + + system_prompt = "You are a meticulous cartographer who labels every landmark." + messages = [{"role": "user", "content": "draw me a map"}] + request_data = { + **self._dispatched_request_data(messages, {}, call_type="aanthropic_messages"), + "system": system_prompt, + } + await self._run(request_data) + + estimated = request_data["combined_usage_object"] + assert isinstance(estimated, Usage) + expected = litellm_module.token_counter(model="gpt-3.5-turbo", messages=messages) + litellm_module.token_counter( + model="gpt-3.5-turbo", text=system_prompt + ) + assert estimated.prompt_tokens == expected + + @pytest.mark.asyncio + async def test_optional_params_system_wins_over_request_body_system(self): + import litellm as litellm_module + from litellm.types.utils import Usage + + dispatched_system = "short dispatched system prompt" + messages = [{"role": "user", "content": "hello"}] + request_data = { + **self._dispatched_request_data(messages, {"system": dispatched_system}), + "system": "a much longer request body system prompt that must not be double counted here", + } + await self._run(request_data) + + estimated = request_data["combined_usage_object"] + assert isinstance(estimated, Usage) + expected = litellm_module.token_counter(model="gpt-3.5-turbo", messages=messages) + litellm_module.token_counter( + model="gpt-3.5-turbo", text=dispatched_system + ) + assert estimated.prompt_tokens == expected + + from typing import cast import litellm diff --git a/tests/test_litellm/test_thinking_enabled.py b/tests/test_litellm/test_thinking_enabled.py index 8ba406c395a..744b258e617 100644 --- a/tests/test_litellm/test_thinking_enabled.py +++ b/tests/test_litellm/test_thinking_enabled.py @@ -60,6 +60,7 @@ class TestIsThinkingEnabled: ({"reasoning_effort": "medium"}, True), # both thinking enabled and reasoning_effort returns True ({"thinking": {"type": "enabled"}, "reasoning_effort": "high"}, True), + ({"thinking": True}, True), # falsy thinking values should not crash ({"thinking": False}, False), ({"thinking": 0}, False), diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index afdfdf170ac..efccdc4a986 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -3766,6 +3766,20 @@ class TestValidateAndFixThinkingParam: assert "budgetTokens" in thinking assert "budget_tokens" not in thinking + def test_bool_true_maps_to_enabled_with_default_budget(self): + from litellm.constants import DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET + from litellm.utils import validate_and_fix_thinking_param + + assert validate_and_fix_thinking_param(thinking=True) == { + "type": "enabled", + "budget_tokens": DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET, + } + + def test_bool_false_returns_none(self): + from litellm.utils import validate_and_fix_thinking_param + + assert validate_and_fix_thinking_param(thinking=False) is None + def test_deepseek_v4_models_in_cost_map(): """ diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mountedServerFields.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mountedServerFields.test.ts new file mode 100644 index 00000000000..5f06d03d595 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mountedServerFields.test.ts @@ -0,0 +1,497 @@ +import { describe, expect, it } from "vitest"; +import { + mountedCreateFieldNames, + mountedEditFieldNames, + projectMountedCreateValues, + projectMountedEditValues, +} from "./mountedServerFields"; + +const editRoot = (values: Record) => mountedEditFieldNames(values).root; +const editCreds = (values: Record) => mountedEditFieldNames(values).credentials; +const createRoot = (values: Record) => mountedCreateFieldNames(values).root; +const createCreds = (values: Record) => mountedCreateFieldNames(values).credentials; + +const HTTP_NONE = { transport: "http", auth_type: "none" }; + +describe("edit root: transport gates", () => { + it("mounts url for http but not spec_path or the stdio group", () => { + const root = editRoot(HTTP_NONE); + expect(root).toContain("url"); + expect(root).not.toContain("spec_path"); + expect(root).not.toContain("command"); + expect(root).not.toContain("stdio_config"); + }); + + it("mounts url for sse", () => { + expect(editRoot({ transport: "sse", auth_type: "none" })).toContain("url"); + }); + + it("mounts spec_path and not url for openapi", () => { + const root = editRoot({ transport: "openapi", auth_type: "none" }); + expect(root).toContain("spec_path"); + expect(root).not.toContain("url"); + }); + + it("swaps the whole auth subtree for the stdio group on stdio", () => { + const root = editRoot({ transport: "stdio", auth_type: "oauth2" }); + expect(root).toStrictEqual([ + "server_name", + "alias", + "description", + "transport", + "max_concurrent_requests", + "command", + "args", + "env_json", + "stdio_config", + "env_vars", + "allow_all_keys", + "available_on_public_internet", + "mcp_access_groups", + "extra_headers", + "static_headers", + ]); + }); + + it("drops every credential on stdio even when auth_type is stored as oauth2", () => { + expect(editCreds({ transport: "stdio", auth_type: "oauth2" })).toStrictEqual([]); + }); +}); + +describe("edit root: auth_type gates", () => { + it("mounts credentials.auth_value only for the four value-bearing auth types", () => { + for (const authType of ["api_key", "bearer_token", "token", "basic"]) { + expect(editCreds({ transport: "http", auth_type: authType })).toStrictEqual(["auth_value"]); + } + expect(editCreds(HTTP_NONE)).toStrictEqual([]); + }); + + it("swaps the oauth2 endpoint set on the M2M flow", () => { + const m2m = editRoot({ transport: "http", auth_type: "oauth2", oauth_flow_type: "m2m" }); + const interactive = editRoot({ transport: "http", auth_type: "oauth2", oauth_flow_type: "interactive" }); + expect(m2m).toContain("token_url"); + expect(m2m).not.toContain("issuer"); + expect(m2m).not.toContain("registration_url"); + expect(interactive).toContain("issuer"); + expect(interactive).toContain("registration_url"); + }); + + it("mounts token_validation_json ONLY on the interactive oauth2 branch", () => { + expect(editRoot({ transport: "http", auth_type: "oauth2", oauth_flow_type: "interactive" })).toContain( + "token_validation_json", + ); + expect(editRoot({ transport: "http", auth_type: "oauth2", oauth_flow_type: "m2m" })).not.toContain( + "token_validation_json", + ); + expect(editRoot({ transport: "http", auth_type: "oauth2_token_exchange" })).not.toContain("token_validation_json"); + expect(editRoot(HTTP_NONE)).not.toContain("token_validation_json"); + }); + + it("mounts token_storage_ttl_seconds only on the interactive oauth2 branch", () => { + expect(editRoot({ transport: "http", auth_type: "oauth2", oauth_flow_type: "interactive" })).toContain( + "token_storage_ttl_seconds", + ); + expect(editRoot({ transport: "http", auth_type: "oauth2", oauth_flow_type: "m2m" })).not.toContain( + "token_storage_ttl_seconds", + ); + }); + + it("gates audience and subject_token_type on the entra_obo token-exchange profile", () => { + const rfc = editRoot({ transport: "http", auth_type: "oauth2_token_exchange", token_exchange_profile: "rfc8693" }); + const entra = editRoot({ + transport: "http", + auth_type: "oauth2_token_exchange", + token_exchange_profile: "entra_obo", + }); + expect(rfc).toContain("audience"); + expect(rfc).toContain("subject_token_type"); + expect(entra).not.toContain("audience"); + expect(entra).not.toContain("subject_token_type"); + expect(entra).toContain("token_exchange_profile"); + }); + + it("mounts the seven aws credentials only for aws_sigv4", () => { + expect(sorted(editCreds({ transport: "http", auth_type: "aws_sigv4" }))).toStrictEqual( + sorted([ + "aws_region_name", + "aws_service_name", + "aws_access_key_id", + "aws_secret_access_key", + "aws_session_token", + "aws_role_name", + "aws_session_name", + ]), + ); + expect(editCreds(HTTP_NONE)).not.toContain("aws_region_name"); + }); + + it("mounts the id-jag credential set only for oauth2_id_jag", () => { + const creds = editCreds({ transport: "http", auth_type: "oauth2_id_jag" }); + expect(creds).toContain("id_jag_resource_token_endpoint"); + expect(creds).toContain("client_private_key"); + expect(creds).toContain("client_assertion_signing_alg"); + expect(editCreds(HTTP_NONE)).not.toContain("id_jag_resource_token_endpoint"); + }); +}); + +describe("edit root: children that gate by early return null", () => { + it("mounts dcr_bridge and the declared-app credentials only for the client-forwarded modes", () => { + for (const authType of ["true_passthrough", "oauth_delegate"]) { + expect(editRoot({ transport: "http", auth_type: authType })).toContain("dcr_bridge"); + expect(sorted(editCreds({ transport: "http", auth_type: authType }))).toStrictEqual( + sorted(["client_id", "client_secret"]), + ); + } + expect(editRoot(HTTP_NONE)).not.toContain("dcr_bridge"); + expect(editRoot({ transport: "http", auth_type: "oauth2" })).not.toContain("dcr_bridge"); + }); + + it("unmounts dcr_bridge with its parent section on stdio", () => { + expect(editRoot({ transport: "stdio", auth_type: "true_passthrough" })).not.toContain("dcr_bridge"); + }); +}); + +describe("edit root: permission-section gates", () => { + it("mounts delegate_auth_to_upstream only for oauth2", () => { + expect(editRoot({ transport: "http", auth_type: "oauth2" })).toContain("delegate_auth_to_upstream"); + expect(editRoot(HTTP_NONE)).not.toContain("delegate_auth_to_upstream"); + expect(editRoot({ transport: "http", auth_type: "api_key" })).not.toContain("delegate_auth_to_upstream"); + }); + + it("mounts oauth_passthrough only for none-auth WITH an Authorization extra header", () => { + expect(editRoot({ ...HTTP_NONE, extra_headers: ["Authorization"] })).toContain("oauth_passthrough"); + expect(editRoot({ ...HTTP_NONE, extra_headers: ["authorization"] })).toContain("oauth_passthrough"); + expect(editRoot({ ...HTTP_NONE, extra_headers: ["X-Other"] })).not.toContain("oauth_passthrough"); + expect(editRoot(HTTP_NONE)).not.toContain("oauth_passthrough"); + expect(editRoot({ transport: "http", auth_type: "oauth2", extra_headers: ["Authorization"] })).not.toContain( + "oauth_passthrough", + ); + }); + + it("treats an absent auth_type as none-auth for the oauth_passthrough gate", () => { + expect(editRoot({ transport: "http", extra_headers: ["Authorization"] })).toContain("oauth_passthrough"); + }); +}); + +describe("create root: where it diverges from edit", () => { + it("mounts source_url, which the edit root has no binding for", () => { + expect(createRoot({ transport: "http", auth_type: "none" })).toContain("source_url"); + expect(editRoot(HTTP_NONE)).not.toContain("source_url"); + }); + + it("gates url on an allow-list, so a blank transport mounts NEITHER url nor auth_type", () => { + const blank = createRoot({ transport: "" }); + expect(blank).not.toContain("url"); + expect(blank).not.toContain("auth_type"); + expect(editRoot({ transport: "" })).toContain("url"); + expect(editRoot({ transport: "" })).toContain("auth_type"); + }); + + it("mounts stdio_config on stdio but never the edit root's command/args/env_json", () => { + const root = createRoot({ transport: "stdio" }); + expect(root).toContain("stdio_config"); + expect(root).not.toContain("command"); + expect(root).not.toContain("args"); + expect(root).not.toContain("env_json"); + }); + + it("mounts the byok fields only for openapi with is_byok on", () => { + expect(createRoot({ transport: "openapi", auth_type: "none" })).toContain("is_byok"); + expect(createRoot({ transport: "openapi", auth_type: "none" })).not.toContain("byok_description"); + const on = createRoot({ transport: "openapi", auth_type: "none", is_byok: true }); + expect(on).toContain("byok_description"); + expect(on).toContain("byok_api_key_help_url"); + expect(createRoot({ transport: "http", auth_type: "none", is_byok: true })).not.toContain("byok_description"); + }); + + it("drops every credential while the transport is unset", () => { + expect(createCreds({ transport: "", auth_type: "aws_sigv4" })).toStrictEqual([]); + expect(createCreds({ transport: "http", auth_type: "aws_sigv4" })).toContain("aws_region_name"); + }); +}); + +const ALWAYS = ["server_name", "alias", "description", "transport", "max_concurrent_requests"]; +const PERMS = [ + "allow_all_keys", + "available_on_public_internet", + "mcp_access_groups", + "extra_headers", + "static_headers", +]; +const sorted = (xs: readonly string[]) => [...xs].sort(); + +const expectEditSets = ( + values: Record, + expected: { root: readonly string[]; credentials: readonly string[] }, +) => { + expect(sorted(editRoot(values))).toStrictEqual(sorted(expected.root)); + expect(sorted(editCreds(values))).toStrictEqual(sorted(expected.credentials)); +}; + +const expectCreateSets = ( + values: Record, + expected: { root: readonly string[]; credentials: readonly string[] }, +) => { + expect(sorted(createRoot(values))).toStrictEqual(sorted(expected.root)); + expect(sorted(createCreds(values))).toStrictEqual(sorted(expected.credentials)); +}; + +describe("edit root: exact mounted set per auth configuration", () => { + it("http + none", () => { + expectEditSets(HTTP_NONE, { root: [...ALWAYS, "url", "auth_type", "env_vars", ...PERMS], credentials: [] }); + }); + + it("http + api_key", () => { + expectEditSets( + { transport: "http", auth_type: "api_key" }, + { root: [...ALWAYS, "url", "auth_type", "env_vars", ...PERMS], credentials: ["auth_value"] }, + ); + }); + + it("http + oauth2 M2M", () => { + expectEditSets( + { transport: "http", auth_type: "oauth2", oauth_flow_type: "m2m" }, + { + root: [ + ...ALWAYS, + "url", + "auth_type", + "oauth_flow_type", + "token_url", + "env_vars", + ...PERMS, + "delegate_auth_to_upstream", + ], + credentials: ["client_id", "client_secret", "token_endpoint_auth_method", "scopes", "upstream_resource"], + }, + ); + }); + + it("http + oauth2 interactive", () => { + expectEditSets( + { transport: "http", auth_type: "oauth2", oauth_flow_type: "interactive" }, + { + root: [ + ...ALWAYS, + "url", + "auth_type", + "oauth_flow_type", + "issuer", + "authorization_url", + "token_url", + "registration_url", + "token_validation_json", + "token_storage_ttl_seconds", + "env_vars", + ...PERMS, + "delegate_auth_to_upstream", + ], + credentials: ["client_id", "client_secret", "scopes", "upstream_resource", "token_endpoint_auth_method"], + }, + ); + }); + + it("http + token exchange, rfc8693", () => { + expectEditSets( + { transport: "http", auth_type: "oauth2_token_exchange", token_exchange_profile: "rfc8693" }, + { + root: [ + ...ALWAYS, + "url", + "auth_type", + "token_exchange_profile", + "token_exchange_endpoint", + "audience", + "subject_token_type", + "env_vars", + ...PERMS, + ], + credentials: ["client_id", "client_secret", "scopes"], + }, + ); + }); + + it("http + token exchange, entra_obo keeps the endpoint while dropping audience and subject_token_type", () => { + expectEditSets( + { transport: "http", auth_type: "oauth2_token_exchange", token_exchange_profile: "entra_obo" }, + { + root: [ + ...ALWAYS, + "url", + "auth_type", + "token_exchange_profile", + "token_exchange_endpoint", + "env_vars", + ...PERMS, + ], + credentials: ["client_id", "client_secret", "scopes"], + }, + ); + }); + + it("http + id-jag", () => { + expectEditSets( + { transport: "http", auth_type: "oauth2_id_jag" }, + { + root: [ + ...ALWAYS, + "url", + "auth_type", + "token_exchange_endpoint", + "audience", + "subject_token_type", + "env_vars", + ...PERMS, + ], + credentials: [ + "id_jag_resource_token_endpoint", + "client_id", + "client_secret", + "client_private_key", + "client_private_key_id", + "client_assertion_signing_alg", + "id_jag_resource", + "scopes", + ], + }, + ); + }); + + it("http + true_passthrough", () => { + expectEditSets( + { transport: "http", auth_type: "true_passthrough" }, + { + root: [...ALWAYS, "url", "auth_type", "dcr_bridge", "env_vars", ...PERMS], + credentials: ["client_id", "client_secret"], + }, + ); + }); + + it("openapi + none", () => { + expectEditSets( + { transport: "openapi", auth_type: "none" }, + { root: [...ALWAYS, "spec_path", "auth_type", "env_vars", ...PERMS], credentials: [] }, + ); + }); +}); + +describe("create root: exact mounted set per configuration", () => { + it("http + none", () => { + expectCreateSets( + { transport: "http", auth_type: "none" }, + { root: [...ALWAYS, "source_url", "url", "auth_type", "env_vars", ...PERMS], credentials: [] }, + ); + }); + + it("openapi with byok on", () => { + expectCreateSets( + { transport: "openapi", auth_type: "none", is_byok: true }, + { + root: [ + ...ALWAYS, + "source_url", + "spec_path", + "is_byok", + "byok_description", + "byok_api_key_help_url", + "auth_type", + "env_vars", + ...PERMS, + ], + credentials: [], + }, + ); + }); + + it("stdio", () => { + expectCreateSets( + { transport: "stdio" }, + { root: [...ALWAYS, "source_url", "stdio_config", "env_vars", ...PERMS], credentials: [] }, + ); + }); + + it("transport still unset", () => { + expectCreateSets({ transport: "" }, { root: [...ALWAYS, "source_url", "env_vars", ...PERMS], credentials: [] }); + }); + + it("http + oauth2 interactive", () => { + expectCreateSets( + { transport: "http", auth_type: "oauth2", oauth_flow_type: "interactive" }, + { + root: [ + ...ALWAYS, + "source_url", + "url", + "auth_type", + "oauth_flow_type", + "issuer", + "authorization_url", + "token_url", + "registration_url", + "token_validation_json", + "token_storage_ttl_seconds", + "env_vars", + ...PERMS, + "delegate_auth_to_upstream", + ], + credentials: ["client_id", "client_secret", "scopes", "upstream_resource", "token_endpoint_auth_method"], + }, + ); + }); + + it("http + oauth_delegate mounts dcr_bridge and the declared app", () => { + expectCreateSets( + { transport: "http", auth_type: "oauth_delegate" }, + { + root: [...ALWAYS, "source_url", "url", "auth_type", "dcr_bridge", "env_vars", ...PERMS], + credentials: ["client_id", "client_secret"], + }, + ); + }); +}); + +describe("projection shape", () => { + it("EMITS a mounted-but-unset field as a key holding undefined, matching antd onFinish", () => { + const projected = projectMountedEditValues({ transport: "http", auth_type: "none", server_name: "s" }); + expect("description" in projected).toBe(true); + expect(projected.description).toBeUndefined(); + expect(Object.keys(projected)).toContain("max_concurrent_requests"); + }); + + it("emits mounted-but-unset CREDENTIAL keys as undefined rather than omitting them", () => { + const projected = projectMountedEditValues({ transport: "http", auth_type: "api_key" }); + expect(Object.keys(projected.credentials as object)).toStrictEqual(["auth_value"]); + expect((projected.credentials as Record).auth_value).toBeUndefined(); + }); + + it("omits the credentials key entirely when no credential field is mounted", () => { + expect("credentials" in projectMountedEditValues(HTTP_NONE)).toBe(false); + }); + + it("drops an unmounted field even when the store still holds a value for it", () => { + const storeWithStaleHttpValues = { + transport: "stdio", + auth_type: "oauth2", + url: "https://kept-in-store.example", + issuer: "https://kept-in-store.example", + command: "npx", + }; + const projected = projectMountedEditValues(storeWithStaleHttpValues); + expect("url" in projected).toBe(false); + expect("issuer" in projected).toBe(false); + expect(projected.command).toBe("npx"); + }); + + it("passes Form.List rows through whole, since antd does not project a row to its mounted sub-fields", () => { + const row = { name: "N", value: "V", scope: "user", description: "D" }; + const projected = projectMountedEditValues({ ...HTTP_NONE, env_vars: [row] }); + expect(projected.env_vars).toStrictEqual([row]); + }); + + it("keeps static_headers rows whole", () => { + const rows = [{ header: "X-A", value: "1" }]; + expect( + projectMountedCreateValues({ transport: "http", auth_type: "none", static_headers: rows }).static_headers, + ).toStrictEqual(rows); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mountedServerFields.ts b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mountedServerFields.ts new file mode 100644 index 00000000000..22f0146afc9 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mountedServerFields.ts @@ -0,0 +1,195 @@ +import { AUTH_TYPE, OAUTH_FLOW, TRANSPORT, isClientForwardedTokenMode } from "@/components/mcp_tools/types"; +import { AUTH_TYPES_REQUIRING_AUTH_VALUE } from "./createServerPayload"; + +export interface MountedFieldNames { + readonly root: readonly string[]; + readonly credentials: readonly string[]; +} + +const ENTRA_OBO_PROFILE = "entra_obo"; + +const ALWAYS_MOUNTED_ROOT = ["server_name", "alias", "description", "transport", "max_concurrent_requests"] as const; + +const PERMISSION_SECTION_ROOT = [ + "allow_all_keys", + "available_on_public_internet", + "mcp_access_groups", + "extra_headers", + "static_headers", +] as const; + +const OAUTH_M2M_CREDENTIALS = [ + "client_id", + "client_secret", + "token_endpoint_auth_method", + "scopes", + "upstream_resource", +] as const; + +const OAUTH_INTERACTIVE_CREDENTIALS = [ + "client_id", + "client_secret", + "scopes", + "upstream_resource", + "token_endpoint_auth_method", +] as const; + +const OAUTH_INTERACTIVE_ROOT = [ + "issuer", + "authorization_url", + "token_url", + "registration_url", + "token_validation_json", + "token_storage_ttl_seconds", +] as const; + +const ID_JAG_CREDENTIALS = [ + "id_jag_resource_token_endpoint", + "client_id", + "client_secret", + "client_private_key", + "client_private_key_id", + "client_assertion_signing_alg", + "id_jag_resource", + "scopes", +] as const; + +const AWS_SIGV4_CREDENTIALS = [ + "aws_region_name", + "aws_service_name", + "aws_access_key_id", + "aws_secret_access_key", + "aws_session_token", + "aws_role_name", + "aws_session_name", +] as const; + +const hasAuthorizationExtraHeader = (extraHeaders: unknown): boolean => + Array.isArray(extraHeaders) && extraHeaders.some((h) => typeof h === "string" && h.toLowerCase() === "authorization"); + +interface AuthSubtreeGates { + readonly authType: string | undefined; + readonly oauthFlowType: string | undefined; + readonly tokenExchangeProfile: string | undefined; +} + +const authSubtreeRoot = ({ authType, oauthFlowType, tokenExchangeProfile }: AuthSubtreeGates): readonly string[] => { + if (authType === AUTH_TYPE.OAUTH2) { + return oauthFlowType === OAUTH_FLOW.M2M + ? ["oauth_flow_type", "token_url"] + : ["oauth_flow_type", ...OAUTH_INTERACTIVE_ROOT]; + } + if (authType === AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE) { + return tokenExchangeProfile === ENTRA_OBO_PROFILE + ? ["token_exchange_profile", "token_exchange_endpoint"] + : ["token_exchange_profile", "token_exchange_endpoint", "audience", "subject_token_type"]; + } + if (authType === AUTH_TYPE.OAUTH2_ID_JAG) { + return ["token_exchange_endpoint", "audience", "subject_token_type"]; + } + return []; +}; + +const authSubtreeCredentials = ({ authType, oauthFlowType }: AuthSubtreeGates): readonly string[] => { + const authValue = AUTH_TYPES_REQUIRING_AUTH_VALUE.includes(authType as string) ? ["auth_value"] : []; + const clientForwarded = isClientForwardedTokenMode(authType) ? ["client_id", "client_secret"] : []; + if (authType === AUTH_TYPE.OAUTH2) { + return [ + ...authValue, + ...(oauthFlowType === OAUTH_FLOW.M2M ? OAUTH_M2M_CREDENTIALS : OAUTH_INTERACTIVE_CREDENTIALS), + ]; + } + if (authType === AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE) { + return [...authValue, "client_id", "client_secret", "scopes"]; + } + if (authType === AUTH_TYPE.OAUTH2_ID_JAG) { + return [...authValue, ...ID_JAG_CREDENTIALS]; + } + if (authType === AUTH_TYPE.AWS_SIGV4) { + return [...authValue, ...AWS_SIGV4_CREDENTIALS]; + } + return [...authValue, ...clientForwarded]; +}; + +const permissionSectionRoot = (authType: string | undefined, extraHeaders: unknown): readonly string[] => { + const isNoneAuth = authType === AUTH_TYPE.NONE || authType == null; + return [ + ...PERMISSION_SECTION_ROOT, + ...(authType === AUTH_TYPE.OAUTH2 ? ["delegate_auth_to_upstream"] : []), + ...(isNoneAuth && hasAuthorizationExtraHeader(extraHeaders) ? ["oauth_passthrough"] : []), + ]; +}; + +const dedupe = (names: readonly string[]): readonly string[] => Array.from(new Set(names)); + +export const mountedEditFieldNames = (values: Record): MountedFieldNames => { + const transport = values.transport as string | undefined; + const isStdio = transport === "stdio"; + const isOpenApi = transport === TRANSPORT.OPENAPI; + const isMcp = !isStdio && !isOpenApi; + const gates: AuthSubtreeGates = { + authType: isStdio ? undefined : (values.auth_type as string | undefined), + oauthFlowType: values.oauth_flow_type as string | undefined, + tokenExchangeProfile: values.token_exchange_profile as string | undefined, + }; + + return { + root: dedupe([ + ...ALWAYS_MOUNTED_ROOT, + ...(isMcp ? ["url"] : []), + ...(isOpenApi ? ["spec_path"] : []), + ...(isStdio ? ["command", "args", "env_json", "stdio_config"] : ["auth_type"]), + ...(isStdio ? [] : authSubtreeRoot(gates)), + ...(!isStdio && isClientForwardedTokenMode(gates.authType) ? ["dcr_bridge"] : []), + "env_vars", + ...permissionSectionRoot(gates.authType, values.extra_headers), + ]), + credentials: isStdio ? [] : dedupe(authSubtreeCredentials(gates)), + }; +}; + +export const mountedCreateFieldNames = (values: Record): MountedFieldNames => { + const transport = values.transport as string | undefined; + const isStdio = transport === "stdio"; + const isOpenApi = transport === TRANSPORT.OPENAPI; + const authSectionMounted = !isStdio && transport !== "" && transport !== undefined; + const gates: AuthSubtreeGates = { + authType: authSectionMounted ? (values.auth_type as string | undefined) : undefined, + oauthFlowType: values.oauth_flow_type as string | undefined, + tokenExchangeProfile: values.token_exchange_profile as string | undefined, + }; + + return { + root: dedupe([ + ...ALWAYS_MOUNTED_ROOT, + "source_url", + ...(transport === "http" || transport === "sse" ? ["url"] : []), + ...(isOpenApi ? ["spec_path", "is_byok"] : []), + ...(isOpenApi && values.is_byok ? ["byok_description", "byok_api_key_help_url"] : []), + ...(authSectionMounted ? ["auth_type"] : []), + ...(authSectionMounted ? authSubtreeRoot(gates) : []), + ...(authSectionMounted && isClientForwardedTokenMode(gates.authType) ? ["dcr_bridge"] : []), + ...(isStdio ? ["stdio_config"] : []), + "env_vars", + ...permissionSectionRoot(gates.authType, values.extra_headers), + ]), + credentials: authSectionMounted ? dedupe(authSubtreeCredentials(gates)) : [], + }; +}; + +const pickEmitting = (source: Record | undefined, names: readonly string[]): Record => + Object.fromEntries(names.map((name) => [name, source?.[name]])); + +const projectWith = + (namesOf: (values: Record) => MountedFieldNames) => + (values: Record): Record => { + const names = namesOf(values); + const credentials = values.credentials as Record | undefined; + return { + ...pickEmitting(values, names.root), + ...(names.credentials.length > 0 ? { credentials: pickEmitting(credentials, names.credentials) } : {}), + }; + }; + +export const projectMountedEditValues = projectWith(mountedEditFieldNames); +export const projectMountedCreateValues = projectWith(mountedCreateFieldNames); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.integration.test.tsx new file mode 100644 index 00000000000..c22ff7ff460 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.integration.test.tsx @@ -0,0 +1,364 @@ +import { renderWithProviders, screen, waitFor } from "../../../../../tests/test-utils"; +import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import AddModelPanel from "./AddModelPanel"; + +const modelCreateCall = vi.fn(); +const mockPtuEnabled = vi.fn(); +const mockAuthorized = vi.fn(); + +vi.mock("@/components/networking", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + modelCreateCall: (accessToken: string, model: unknown) => modelCreateCall(accessToken, model), + modelAvailableCall: vi.fn().mockResolvedValue({ data: [{ id: "group-a" }] }), + }; +}); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => mockAuthorized() })); + +vi.mock("@/app/(dashboard)/hooks/uiSettings/usePtuCostAttributionEnabled", () => ({ + usePtuCostAttributionEnabled: () => mockPtuEnabled(), +})); + +vi.mock("@/app/(dashboard)/hooks/models/useModelCostMap", () => ({ useModelCostMap: () => ({ data: {} }) })); + +vi.mock("@/app/(dashboard)/hooks/credentials/useCredentials", () => ({ + useCredentials: () => ({ data: { credentials: [] } }), +})); + +vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ + useTeams: () => ({ data: [] }), + useInfiniteTeams: () => ({ + data: { pages: [{ teams: [], total: 0, page: 1, page_size: 20, total_pages: 1 }] }, + fetchNextPage: vi.fn(), + hasNextPage: false, + isFetchingNextPage: false, + isLoading: false, + }), +})); + +vi.mock("@/app/(dashboard)/hooks/guardrails/useGuardrails", () => ({ + useGuardrails: () => ({ data: { guardrails: [{ guardrail_name: "g-1" }] }, isLoading: false, error: null }), +})); + +vi.mock("@/app/(dashboard)/hooks/tags/useTags", () => ({ + useTags: () => ({ data: {}, isLoading: false, error: null }), +})); + +vi.mock("@/app/(dashboard)/hooks/providers/useProviderFields", () => ({ + useProviderFields: () => ({ + data: [ + { + provider: "OpenAI", + provider_display_name: "OpenAI", + litellm_provider: "openai", + default_model_placeholder: "gpt-4o", + credential_fields: [ + { key: "api_key", label: "API Key", field_type: "password", required: false }, + { key: "api_base", label: "API Base", field_type: "text", required: false }, + ], + }, + ], + isLoading: false, + error: null, + }), +})); + +vi.mock("@/components/vector_store_management/VectorStoreSelector", () => ({ + default: () =>
, +})); + +const lastCreatedModel = () => modelCreateCall.mock.calls.at(-1)?.[1]; + +const PROXY_ADMIN = { + token: "t", + accessToken: "test-access-token", + userId: "user-1", + userEmail: "a@b.c", + userRole: "proxy_admin", + premiumUser: true, + disabledPersonalKeyCreation: false, + showSSOBanner: false, +}; + +const alwaysMounted = { + api_key: undefined, + api_base: undefined, + custom_llm_provider: "openai", + litellm_credential_name: null, + model: "gpt-4o", +}; + +const advancedOpenExtras = { + guardrails: undefined, + tags: undefined, + use_in_pass_through: undefined, + vector_store_ids: undefined, +}; + +const baseModelInfo = { access_groups: undefined, mode: undefined }; + +const { api_base: _omitted, ...ALWAYS_MOUNTED_WITHOUT_API_BASE } = alwaysMounted; + +const setup = async () => { + const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never }); + renderWithProviders(); + await screen.findByText("Provider"); + + const openAdvanced = async () => { + await user.click(screen.getByText("Advanced Settings")); + await screen.findByText("Tags"); + }; + + const closeAdvanced = async () => { + await user.click(screen.getByText("Advanced Settings")); + await waitFor(() => expect(screen.queryByText("Tags")).not.toBeInTheDocument()); + }; + + const fillRequired = async (modelName = "gpt-4o") => { + await user.click(screen.getByRole("combobox", { name: /provider/i })); + await user.click(await screen.findByText("OpenAI")); + await user.type(await screen.findByPlaceholderText("gpt-3.5-turbo"), modelName); + }; + + const submit = async () => { + await user.click(screen.getByTestId("add-model-btn")); + await waitFor(() => expect(modelCreateCall).toHaveBeenCalled()); + }; + + const submitExpectingRejection = async (message: string) => { + await user.click(screen.getByTestId("add-model-btn")); + await screen.findByText(message); + }; + + return { user, openAdvanced, closeAdvanced, fillRequired, submit, submitExpectingRejection }; +}; + +describe("AddModelPanel submit payload contract", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockPtuEnabled.mockReturnValue(false); + mockAuthorized.mockReturnValue(PROXY_ADMIN); + }); + + it("sends only the always-mounted fields while Advanced Settings stays closed", async () => { + const { fillRequired, submit } = await setup(); + await fillRequired(); + await submit(); + + expect(lastCreatedModel()).toStrictEqual({ + model_name: "gpt-4o", + litellm_params: { ...alwaysMounted }, + model_info: { ...baseModelInfo }, + }); + }); + + it("registers four more keys as undefined once Advanced Settings opens", async () => { + const { openAdvanced, fillRequired, submit } = await setup(); + await fillRequired(); + await openAdvanced(); + await submit(); + + expect(lastCreatedModel()).toStrictEqual({ + model_name: "gpt-4o", + litellm_params: { ...alwaysMounted, ...advancedOpenExtras }, + model_info: { ...baseModelInfo }, + }); + }); + + it("merges typed LiteLLM Params into litellm_params", async () => { + const { user, openAdvanced, fillRequired, submit } = await setup(); + await fillRequired(); + await openAdvanced(); + await user.type(screen.getByLabelText("LiteLLM Params"), '{{"rpm": 7}'); + await submit(); + + expect(lastCreatedModel()).toStrictEqual({ + model_name: "gpt-4o", + litellm_params: { ...alwaysMounted, ...advancedOpenExtras, rpm: 7 }, + model_info: { ...baseModelInfo }, + }); + }); + + it("drops a collapsed section's keys and the value typed into it", async () => { + const { user, openAdvanced, closeAdvanced, fillRequired, submit } = await setup(); + await fillRequired(); + await openAdvanced(); + await user.type(screen.getByLabelText("LiteLLM Params"), '{{"rpm": 7}'); + await closeAdvanced(); + await submit(); + + expect(lastCreatedModel()).toStrictEqual({ + model_name: "gpt-4o", + litellm_params: { ...alwaysMounted }, + model_info: { ...baseModelInfo }, + }); + }); + + it("restores the typed value when the section is expanded again", async () => { + const { user, openAdvanced, closeAdvanced, fillRequired, submit } = await setup(); + await fillRequired(); + await openAdvanced(); + await user.type(screen.getByLabelText("LiteLLM Params"), '{{"rpm": 7}'); + await closeAdvanced(); + await openAdvanced(); + expect(screen.getByLabelText("LiteLLM Params")).toHaveValue('{"rpm": 7}'); + + await submit(); + + expect(lastCreatedModel()).toStrictEqual({ + model_name: "gpt-4o", + litellm_params: { ...alwaysMounted, ...advancedOpenExtras, rpm: 7 }, + model_info: { ...baseModelInfo }, + }); + }); + + it("converts per-million pricing to per-token and falls back to input cost for cache reads", async () => { + const { user, openAdvanced, fillRequired, submit } = await setup(); + await fillRequired(); + await openAdvanced(); + await user.click(screen.getByLabelText("Custom Pricing")); + await user.type(await screen.findByLabelText("Input Cost (per 1M tokens)"), "3"); + await user.type(screen.getByLabelText("Output Cost (per 1M tokens)"), "9"); + await submit(); + + expect(lastCreatedModel()).toStrictEqual({ + model_name: "gpt-4o", + litellm_params: { + ...alwaysMounted, + ...advancedOpenExtras, + input_cost_per_token: 0.000003, + output_cost_per_token: 0.000009, + cache_read_input_token_cost: 0.000003, + }, + model_info: { ...baseModelInfo }, + }); + }); + + it("sends the seeded injection point when cache control is switched on", async () => { + const { user, openAdvanced, fillRequired, submit } = await setup(); + await fillRequired(); + await openAdvanced(); + await user.click(screen.getByLabelText("Cache Control Injection Points")); + await screen.findByText("Add Injection Point"); + await submit(); + + expect(lastCreatedModel()).toStrictEqual({ + model_name: "gpt-4o", + litellm_params: { + ...alwaysMounted, + ...advancedOpenExtras, + cache_control_injection_points: [{ location: "message" }], + }, + model_info: { ...baseModelInfo }, + }); + }); + + it("carries a role picked inside the injection point editor, with the index kept a string", async () => { + const { user, openAdvanced, fillRequired, submit } = await setup(); + await fillRequired(); + await openAdvanced(); + await user.click(screen.getByLabelText("Cache Control Injection Points")); + await screen.findByText("Add Injection Point"); + await user.click(screen.getByText("Select a role")); + await user.click(await screen.findByText("System")); + await user.type(screen.getByPlaceholderText("Optional"), "3"); + await submit(); + + expect(lastCreatedModel()).toStrictEqual({ + model_name: "gpt-4o", + litellm_params: { + ...alwaysMounted, + ...advancedOpenExtras, + cache_control_injection_points: [{ location: "message", role: "system", index: "3" }], + }, + model_info: { ...baseModelInfo }, + }); + }); + + it("mounts team_id only once the Team-BYOK switch is on", async () => { + const { user, fillRequired, submit } = await setup(); + await fillRequired(); + await user.click(screen.getByRole("switch", { name: "Team-BYOK Model" })); + await screen.findByText("Select Team"); + await submit(); + + expect(lastCreatedModel()).toStrictEqual({ + model_name: "gpt-4o", + litellm_params: { ...alwaysMounted }, + model_info: { ...baseModelInfo, team_id: undefined }, + }); + }); +}); + +describe("AddModelPanel empty-string skip", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockPtuEnabled.mockReturnValue(false); + mockAuthorized.mockReturnValue(PROXY_ADMIN); + }); + + it("sends a typed api_base, so the binding behind the next case is known to be live", async () => { + const { user, fillRequired, submit } = await setup(); + await fillRequired(); + await user.type(screen.getByLabelText("API Base"), "https://example.test"); + await submit(); + + expect(lastCreatedModel().litellm_params).toStrictEqual({ + ...alwaysMounted, + api_base: "https://example.test", + }); + }); + + it("omits api_base entirely once it is cleared, rather than sending an empty string", async () => { + const { user, fillRequired, submit } = await setup(); + await fillRequired(); + const apiBase = screen.getByLabelText("API Base"); + await user.type(apiBase, "https://example.test"); + await user.clear(apiBase); + await submit(); + + const params = lastCreatedModel().litellm_params; + expect(params).not.toHaveProperty("api_base"); + expect(params).toStrictEqual(ALWAYS_MOUNTED_WITHOUT_API_BASE); + }); +}); + +describe("AddModelPanel validation gates", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockPtuEnabled.mockReturnValue(true); + mockAuthorized.mockReturnValue(PROXY_ADMIN); + }); + + it("blocks the submit when a PTU count carries no effective-from date", async () => { + const { user, openAdvanced, fillRequired, submitExpectingRejection } = await setup(); + await fillRequired(); + await openAdvanced(); + await user.type(screen.getByLabelText("PTU Count"), "15"); + await user.type(screen.getByLabelText("Calculated Cost per PTU / Hour (USD)"), "2"); + await submitExpectingRejection("PTU Effective From is required when PTU Count is set"); + + expect(modelCreateCall).not.toHaveBeenCalled(); + }); + + it("hides the PTU fields entirely when the capability is off", async () => { + mockPtuEnabled.mockReturnValue(false); + const { openAdvanced, fillRequired } = await setup(); + await fillRequired(); + await openAdvanced(); + + expect(screen.queryByLabelText("PTU Count")).not.toBeInTheDocument(); + }); + + it("requires a model before anything is sent", async () => { + const { user, submitExpectingRejection } = await setup(); + await user.click(screen.getByRole("combobox", { name: /provider/i })); + await user.click(await screen.findByText("OpenAI")); + await submitExpectingRejection("Please enter at least one model."); + + expect(modelCreateCall).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx b/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx index d3ede5f4318..c3936cc456d 100644 --- a/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx +++ b/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx @@ -40,6 +40,7 @@ export const MODEL_SENTINEL_OPTIONS = [ const MAX_VISIBLE_MODEL_CHIPS = 5; export interface ModelSelectProps { + id?: string; teamID?: string; organizationID?: string; options?: { @@ -122,7 +123,7 @@ const filterModels = ( export const ModelSelect = (props: ModelSelectProps) => { const anchor = useComboboxAnchor(); - const { teamID, organizationID, options, context, dataTestId, value = [], onChange, style } = props; + const { id, teamID, organizationID, options, context, dataTestId, value = [], onChange, style } = props; const { showAllProxyModelsOverride, includeSpecialOptions } = options || {}; const { data: allProxyModels, isLoading: isLoadingAllProxyModels } = useAllProxyModels(); const { data: team, isLoading: isLoadingTeam } = useTeam(teamID); @@ -256,7 +257,7 @@ export const ModelSelect = (props: ModelSelectProps) => { )} - + No models found diff --git a/ui/litellm-dashboard/src/components/Teams.test.tsx b/ui/litellm-dashboard/src/components/Teams.test.tsx index 0ef4357e73f..d0d41837dce 100644 --- a/ui/litellm-dashboard/src/components/Teams.test.tsx +++ b/ui/litellm-dashboard/src/components/Teams.test.tsx @@ -1223,3 +1223,261 @@ describe("Teams - which fields reach the create payload depends on the open sect expect(payload.team_id).toBe("tid-kept"); }); }); + +describe("Teams - the exact bytes the create call sends", () => { + beforeEach(() => { + vi.clearAllMocks(); + can.mockReturnValue(true); + vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue(["gpt-4"]); + vi.mocked(fetchMCPAccessGroups).mockResolvedValue([]); + vi.mocked(getGuardrailsList).mockResolvedValue({ guardrails: [] }); + vi.mocked(getPoliciesList).mockResolvedValue({ policies: [] }); + vi.mocked(getDefaultTeamSettings).mockResolvedValue({ values: {} }); + vi.mocked(teamCreateCall).mockResolvedValue({ team_id: "new-team-1" }); + vi.mocked(useTeamMetadataSchema).mockReturnValue({ data: [], isLoading: false } as any); + mockUseOrganizations.mockReturnValue({ data: null }); + }); + + const openCreateModal = async (options?: { premiumUser?: boolean }) => { + renderWithQueryClient( + , + ); + act(() => { + fireEvent.click(screen.getAllByRole("button", { name: /create team/i })[0]); + }); + await waitFor(() => { + expect(screen.getByLabelText(/team name/i)).toBeInTheDocument(); + }); + fireEvent.change(screen.getByLabelText(/team name/i), { target: { value: "Byte Contract Team" } }); + }; + + const submit = async () => { + const buttons = screen.getAllByRole("button", { name: /create team/i }); + fireEvent.click(buttons[buttons.length - 1]); + await waitFor(() => { + expect(teamCreateCall).toHaveBeenCalled(); + }); + return vi.mocked(teamCreateCall).mock.calls[0][1] as Record; + }; + + const wireBody = (payload: Record) => JSON.parse(JSON.stringify(payload)) as Record; + + const openSection = async (title: string, mountedProbe: RegExp | string) => { + fireEvent.click(screen.getByText(title)); + await waitFor(() => { + expect(screen.getAllByText(mountedProbe).length).toBeGreaterThan(0); + }); + }; + + it("sends three keys and nothing else when every section is left closed", async () => { + await openCreateModal(); + + const payload = await submit(); + + expect(payload).toStrictEqual({ + team_alias: "Byte Contract Team", + organization_id: null, + models: ["no-default-models"], + max_budget: undefined, + budget_duration: undefined, + tpm_limit: undefined, + rpm_limit: undefined, + metadata: undefined, + }); + expect(wireBody(payload)).toStrictEqual({ + team_alias: "Byte Contract Team", + organization_id: null, + models: ["no-default-models"], + }); + }); + + it("keeps every newly mounted but untouched field out of the request body", async () => { + await openCreateModal(); + + await openSection("Additional Settings", /Team Member Key Duration/); + await openSection("MCP Settings", /Allowed MCP Servers/); + await openSection("Agent Settings", /Allowed Agents/); + await openSection("Search Tool Settings", /Allowed Search Tools/); + + const payload = await submit(); + + expect(payload).toStrictEqual({ + team_alias: "Byte Contract Team", + organization_id: null, + models: ["no-default-models"], + max_budget: undefined, + budget_duration: undefined, + tpm_limit: undefined, + rpm_limit: undefined, + metadata: undefined, + team_id: undefined, + team_member_budget: undefined, + team_member_key_duration: undefined, + team_member_rpm_limit: undefined, + team_member_tpm_limit: undefined, + secret_manager_settings: undefined, + guardrails: undefined, + disable_global_guardrails: undefined, + policies: undefined, + access_group_ids: undefined, + allowed_vector_store_ids: undefined, + allowed_passthrough_routes: undefined, + allowed_mcp_servers_and_groups: undefined, + mcp_tool_permissions: {}, + allowed_agents_and_groups: undefined, + object_permission_search_tools: undefined, + }); + expect(wireBody(payload)).toStrictEqual({ + team_alias: "Byte Contract Team", + organization_id: null, + models: ["no-default-models"], + mcp_tool_permissions: {}, + }); + }); + + it.each([ + ["MCP Settings", /Allowed MCP Servers/, ["allowed_mcp_servers_and_groups", "mcp_tool_permissions"]], + ["Agent Settings", /Allowed Agents/, ["allowed_agents_and_groups"]], + ["Search Tool Settings", /Allowed Search Tools/, ["object_permission_search_tools"]], + ])("registers %s fields only while that one section is open", async (title, probe, keys) => { + await openCreateModal(); + + const closedPayload = await submit(); + for (const key of keys as string[]) { + expect(closedPayload).not.toHaveProperty(key); + } + }); + + it("carries every typed value to the payload at the type antd sends today", async () => { + await openCreateModal(); + + fireEvent.change(screen.getByLabelText("Max Budget (USD)"), { target: { value: "150.75" } }); + fireEvent.change(screen.getByLabelText("Tokens per minute Limit (TPM)"), { target: { value: "900" } }); + fireEvent.change(screen.getByLabelText("Requests per minute Limit (RPM)"), { target: { value: "800" } }); + + await openSection("Additional Settings", /Team Member Key Duration/); + + fireEvent.change(screen.getByLabelText("Team ID"), { target: { value: "tid-1" } }); + fireEvent.change(screen.getByLabelText("Team Member Budget (USD)"), { target: { value: "12.5" } }); + fireEvent.change(screen.getByLabelText(/Team Member Key Duration/), { target: { value: "30d" } }); + fireEvent.change(screen.getByLabelText("Team Member RPM Limit"), { target: { value: "7" } }); + fireEvent.change(screen.getByLabelText("Team Member TPM Limit"), { target: { value: "8" } }); + fireEvent.change(screen.getByLabelText("Secret Manager Settings"), { + target: { value: '{"namespace":"admin"}' }, + }); + + const payload = await submit(); + + expect(payload.max_budget).toBe("150.75"); + expect(payload.tpm_limit).toBe("900"); + expect(payload.rpm_limit).toBe("800"); + expect(payload.team_id).toBe("tid-1"); + expect(payload.team_member_budget).toBe(12.5); + expect(payload.team_member_key_duration).toBe("30d"); + expect(payload.team_member_rpm_limit).toBe("7"); + expect(payload.team_member_tpm_limit).toBe("8"); + expect(payload.secret_manager_settings).toStrictEqual({ namespace: "admin" }); + }); + + it("blocks the create on an invalid secret manager config, with the rule message suppressed by help", async () => { + await openCreateModal(); + await openSection("Additional Settings", /Team Member Key Duration/); + + fireEvent.change(screen.getByLabelText("Secret Manager Settings"), { target: { value: " " } }); + + const buttons = screen.getAllByRole("button", { name: /create team/i }); + fireEvent.click(buttons[buttons.length - 1]); + + await waitFor(() => { + expect(screen.getByLabelText("Secret Manager Settings")).toHaveAttribute("aria-invalid", "true"); + }); + expect(teamCreateCall).not.toHaveBeenCalled(); + expect(screen.queryByText("Please enter valid JSON")).not.toBeInTheDocument(); + }); + + it("turns the disable-global-guardrails switch into a boolean for a premium user", async () => { + await openCreateModal({ premiumUser: true }); + await openSection("Additional Settings", /Team Member Key Duration/); + + const switches = screen.getAllByRole("switch"); + fireEvent.click(switches[switches.length - 1]); + + const payload = await submit(); + + expect(payload.disable_global_guardrails).toBe(true); + }); + + it("leaves the disable-global-guardrails switch inert for a non-premium user", async () => { + await openCreateModal(); + await openSection("Additional Settings", /Team Member Key Duration/); + + const switches = screen.getAllByRole("switch"); + fireEvent.click(switches[switches.length - 1]); + + const payload = await submit(); + + expect(payload.disable_global_guardrails).toBeUndefined(); + }); + + it.each([ + ["MCP Settings", /Allowed MCP Servers/, ["allowed_mcp_servers_and_groups", "mcp_tool_permissions"]], + ["Agent Settings", /Allowed Agents/, ["allowed_agents_and_groups"]], + ["Search Tool Settings", /Allowed Search Tools/, ["object_permission_search_tools"]], + ])("adds the %s keys as soon as that one section is opened", async (title, probe, keys) => { + await openCreateModal(); + + await openSection(title as string, probe as RegExp); + const payload = await submit(); + + for (const key of keys as string[]) { + expect(payload).toHaveProperty(key); + } + }); + + it("leaves policies out of the request body for a caller without the viewPolicies capability", async () => { + can.mockReturnValue(false); + + await openCreateModal(); + await openSection("Additional Settings", /Team Member Key Duration/); + + const payload = await submit(); + + expect(payload).toStrictEqual({ + team_alias: "Byte Contract Team", + organization_id: null, + models: ["no-default-models"], + max_budget: undefined, + budget_duration: undefined, + tpm_limit: undefined, + rpm_limit: undefined, + metadata: undefined, + team_id: undefined, + team_member_budget: undefined, + team_member_key_duration: undefined, + team_member_rpm_limit: undefined, + team_member_tpm_limit: undefined, + secret_manager_settings: undefined, + guardrails: undefined, + disable_global_guardrails: undefined, + access_group_ids: undefined, + allowed_vector_store_ids: undefined, + allowed_passthrough_routes: undefined, + }); + }); + + it("blocks the create on an empty team name and names the rule", async () => { + renderWithQueryClient(); + act(() => { + fireEvent.click(screen.getAllByRole("button", { name: /create team/i })[0]); + }); + await waitFor(() => { + expect(screen.getByLabelText(/team name/i)).toBeInTheDocument(); + }); + + const buttons = screen.getAllByRole("button", { name: /create team/i }); + fireEvent.click(buttons[buttons.length - 1]); + + expect(await screen.findByText("Please input a team name")).toBeInTheDocument(); + expect(teamCreateCall).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/Teams.tsx b/ui/litellm-dashboard/src/components/Teams.tsx index b1f96f51028..94e84c6e721 100644 --- a/ui/litellm-dashboard/src/components/Teams.tsx +++ b/ui/litellm-dashboard/src/components/Teams.tsx @@ -4,12 +4,21 @@ import AvailableTeamsPanel from "@/components/team/AvailableTeamsPanel"; import TeamInfoView from "@/components/team/TeamInfo"; import TeamSSOSettings from "@/components/TeamSSOSettings"; import { isProxyAdminRole } from "@/utils/roles"; -import { InfoCircleOutlined } from "@ant-design/icons"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Input as UIInput } from "@/components/ui/input"; -import { Button, Form, Input, Layout, Modal, Select, Switch, Tabs, theme, Tooltip, Typography } from "antd"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/shared/form/field"; +import { FormField } from "@/components/shared/form/FormField"; +import { SearchSelect } from "@/components/shared/SearchSelect"; +import { labelWithDocsHint, labelWithHint } from "@/components/shared/form/LabelWithHint"; +import { useZodForm } from "@/lib/forms/useZodForm"; +import { TagsInput } from "@/app/(dashboard)/guardrails/_components/content_filter/TagsInput"; +import { Layout, Modal, Tabs, theme } from "antd"; import { ChevronDown, Plus, Users } from "lucide-react"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; +import { z } from "zod/v4"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { PageHeader } from "@/components/shared/PageHeader"; import { Button as UIButton } from "@/components/ui/button"; @@ -17,7 +26,10 @@ import { teamsTableKeys } from "@/app/(dashboard)/hooks/teams/useTeams"; import { parseAsString, useQueryState } from "nuqs"; import { TeamsTable } from "./TeamsPage/TeamsTable"; import AccessGroupSelector from "./common_components/AccessGroupSelector"; -import MetadataKeyValueFields, { metadataPairsToObject } from "./common_components/MetadataKeyValueFields"; +import MetadataKeyValueFields, { + metadataPairsSchema, + metadataPairsToObject, +} from "./common_components/MetadataKeyValueFields"; import { useTeamMetadataSchema } from "@/app/(dashboard)/hooks/teams/useTeamMetadataSchema"; import PassThroughRoutesSelector from "./common_components/PassThroughRoutesSelector"; import AgentSelector from "./agent_management/AgentSelector"; @@ -51,6 +63,102 @@ import { teamCreateCall } from "./networking"; import { normalizeTeamModelSelection } from "./team/teamModelAccess"; import { ModelSelect } from "./ModelSelect/ModelSelect"; +const SUPPRESSED_BY_DESCRIPTION = ""; + +const numericInputSchema = z.union([z.string(), z.number()]).optional(); + +const teamCreateFieldsSchema = z.object({ + team_alias: z.string().min(1, "Please input a team name"), + organization_id: z.string().nullish(), + models: z.array(z.string()).optional(), + max_budget: numericInputSchema, + budget_duration: z.string().nullish(), + tpm_limit: numericInputSchema, + rpm_limit: numericInputSchema, + metadata: metadataPairsSchema.optional(), + team_id: z.string().optional(), + team_member_budget: z.number().optional(), + team_member_key_duration: z.string().optional(), + team_member_rpm_limit: numericInputSchema, + team_member_tpm_limit: numericInputSchema, + secret_manager_settings: z.string().optional(), + guardrails: z.array(z.string()).optional(), + disable_global_guardrails: z.boolean().optional(), + policies: z.array(z.string()).optional(), + access_group_ids: z.array(z.string()).optional(), + allowed_vector_store_ids: z.array(z.string()).optional(), + allowed_passthrough_routes: z.array(z.string()).optional(), + allowed_mcp_servers_and_groups: z + .object({ + servers: z.array(z.string()), + accessGroups: z.array(z.string()), + toolsets: z.array(z.string()).optional(), + }) + .optional(), + mcp_tool_permissions: z.record(z.string(), z.array(z.string())).optional(), + allowed_agents_and_groups: z.object({ agents: z.array(z.string()), accessGroups: z.array(z.string()) }).optional(), + object_permission_search_tools: z.array(z.string()).optional(), +}); + +type TeamCreateFormValues = z.infer; + +const EMPTY_TEAM_CREATE_VALUES: TeamCreateFormValues = { + team_alias: "", + organization_id: null, + models: [], + max_budget: undefined, + budget_duration: undefined, + tpm_limit: undefined, + rpm_limit: undefined, + metadata: [], + team_id: undefined, + team_member_budget: undefined, + team_member_key_duration: undefined, + team_member_rpm_limit: undefined, + team_member_tpm_limit: undefined, + secret_manager_settings: undefined, + guardrails: undefined, + disable_global_guardrails: undefined, + policies: undefined, + access_group_ids: undefined, + allowed_vector_store_ids: undefined, + allowed_passthrough_routes: undefined, + allowed_mcp_servers_and_groups: undefined, + mcp_tool_permissions: {}, + allowed_agents_and_groups: undefined, + object_permission_search_tools: undefined, +}; + +const ADDITIONAL_SETTINGS_FIELDS = [ + "team_id", + "team_member_budget", + "team_member_key_duration", + "team_member_rpm_limit", + "team_member_tpm_limit", + "secret_manager_settings", + "guardrails", + "disable_global_guardrails", + "policies", + "access_group_ids", + "allowed_vector_store_ids", + "allowed_passthrough_routes", +] as const; +const MCP_SETTINGS_FIELDS = ["allowed_mcp_servers_and_groups", "mcp_tool_permissions"] as const; +const AGENT_SETTINGS_FIELDS = ["allowed_agents_and_groups"] as const; +const SEARCH_TOOL_SETTINGS_FIELDS = ["object_permission_search_tools"] as const; + +const isParsableJson = (value: string | undefined): boolean => { + if (!value) { + return true; + } + try { + JSON.parse(value); + return true; + } catch { + return false; + } +}; + const canCreateOrManageTeams = ( userRole: string | null, userID: string | null, @@ -101,7 +209,29 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser const [currentOrg] = useState(null); const [currentOrgForCreateTeam, setCurrentOrgForCreateTeam] = useState(null); - const [form] = Form.useForm(); + const isOrgAdmin = userRole !== "Admin"; + const [additionalSettingsOpen, setAdditionalSettingsOpen] = useState(false); + const [mcpSettingsOpen, setMcpSettingsOpen] = useState(false); + const [agentSettingsOpen, setAgentSettingsOpen] = useState(false); + const [searchToolSettingsOpen, setSearchToolSettingsOpen] = useState(false); + + const teamCreateSchema = useMemo( + () => + teamCreateFieldsSchema.superRefine((values, ctx) => { + if (isOrgAdmin && !values.organization_id) { + ctx.addIssue({ code: "custom", message: SUPPRESSED_BY_DESCRIPTION, path: ["organization_id"] }); + } + if (additionalSettingsOpen && !isParsableJson(values.secret_manager_settings)) { + ctx.addIssue({ code: "custom", message: SUPPRESSED_BY_DESCRIPTION, path: ["secret_manager_settings"] }); + } + }), + [isOrgAdmin, additionalSettingsOpen], + ); + + const form = useZodForm(teamCreateSchema, { defaultValues: EMPTY_TEAM_CREATE_VALUES }); + const watchedOrganizationId = form.watch("organization_id"); + const watchedMcpSelection = form.watch("allowed_mcp_servers_and_groups"); + const watchedToolPermissions = form.watch("mcp_tool_permissions"); const [selectedTeam, setSelectedTeam] = useState(null); const [selectedTeamId, setSelectedTeamId] = useQueryState("team", parseAsString.withOptions({ history: "push" })); @@ -134,27 +264,26 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser : "n/a"; useEffect(() => { - form.setFieldValue("models", []); + form.setValue("models", []); }, [currentOrgForCreateTeam, userModels]); // Handle organization preselection when modal opens useEffect(() => { if (isTeamModalVisible) { const adminOrgs = getAdminOrganizations(userRole, userID, organizations); - const isOrgAdmin = userRole !== "Admin"; // Org admins must scope a team to an org, so with exactly one we preselect it. // Proxy admins can create org-less teams, so the field stays optional regardless of org count. if (isOrgAdmin && adminOrgs.length === 1) { const org = adminOrgs[0]; - form.setFieldValue("organization_id", org.organization_id); + form.setValue("organization_id", org.organization_id); setCurrentOrgForCreateTeam(org); } else { - form.setFieldValue("organization_id", currentOrg?.organization_id || null); + form.setValue("organization_id", currentOrg?.organization_id || null); setCurrentOrgForCreateTeam(currentOrg); } } - }, [isTeamModalVisible, userRole, userID, organizations, currentOrg]); + }, [isTeamModalVisible, isOrgAdmin, userRole, userID, organizations, currentOrg]); // Add this useEffect to fetch guardrails useEffect(() => { @@ -190,22 +319,26 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser if (canViewPolicies) fetchPolicies(); }, [accessToken, canViewPolicies]); - const handleOk = () => { - setIsTeamModalVisible(false); - form.resetFields(); + const resetCreateForm = () => { + form.reset(EMPTY_TEAM_CREATE_VALUES); + setAdditionalSettingsOpen(false); + setMcpSettingsOpen(false); + setAgentSettingsOpen(false); + setSearchToolSettingsOpen(false); setLoggingSettings([]); setModelAliases({}); setRouterSettings(null); setRouterSettingsKey((prev) => prev + 1); }; + const handleOk = () => { + setIsTeamModalVisible(false); + resetCreateForm(); + }; + const handleCancel = () => { setIsTeamModalVisible(false); - form.resetFields(); - setLoggingSettings([]); - setModelAliases({}); - setRouterSettings(null); - setRouterSettingsKey((prev) => prev + 1); + resetCreateForm(); }; const handleDelete = async (team: Team) => { @@ -378,11 +511,7 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser await teamCreateCall(accessToken, { ...formValues, models: normalizeTeamModelSelection(formValues.models) }); toast.success("Team created"); await refreshTeams(); - form.resetFields(); - setLoggingSettings([]); - setModelAliases({}); - setRouterSettings(null); - setRouterSettingsKey((prev) => prev + 1); + resetCreateForm(); setIsTeamModalVisible(false); } } catch (error) { @@ -391,6 +520,19 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser } }; + const mountedCreateValues = (values: TeamCreateFormValues): Record => { + const unmounted = new Set([ + ...(additionalSettingsOpen ? [] : ADDITIONAL_SETTINGS_FIELDS), + ...(additionalSettingsOpen && canViewPolicies ? [] : ["policies"]), + ...(mcpSettingsOpen ? [] : MCP_SETTINGS_FIELDS), + ...(agentSettingsOpen ? [] : AGENT_SETTINGS_FIELDS), + ...(searchToolSettingsOpen ? [] : SEARCH_TOOL_SETTINGS_FIELDS), + ]); + return Object.fromEntries(Object.entries(values).filter(([key]) => !unmounted.has(key))); + }; + + const onCreateSubmit = (values: TeamCreateFormValues) => handleCreate(mountedCreateValues(values)); + const is_team_admin = (team: any) => { if (team == null || team.members_with_roles == null) { return false; @@ -405,7 +547,6 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser }; const { token } = theme.useToken(); - const { Text } = Typography; const { Content } = Layout; const tabItems = [ @@ -531,556 +672,538 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser onCancel={handleCancel} destroyOnHidden > -
- <> - - - - {(() => { - const adminOrgs = getAdminOrganizations(userRole, userID, organizations); - const isOrgAdmin = userRole !== "Admin"; - const isSingleOrg = adminOrgs.length === 1; - const hasNoOrgs = adminOrgs.length === 0; - - return ( - <> - - Organization{" "} - - Organizations can have multiple teams. Learn more about{" "} - e.stopPropagation()} - > - user management hierarchy - - - } - > - - - - } - name="organization_id" - initialValue={currentOrg ? currentOrg.organization_id : null} - className="mt-8" - rules={ - isOrgAdmin - ? [ - { - required: true, - message: "Please select an organization", - }, - ] - : [] - } - help={ - isOrgAdmin && isSingleOrg - ? "You can only create teams within this organization" - : isOrgAdmin - ? "required" - : "" - } - > - - - - {/* Show message when org admin needs to select organization */} - {isOrgAdmin && !isSingleOrg && adminOrgs.length > 1 && ( -
- - Please select an organization to create a team for. You can only create teams within - organizations where you are an admin. - -
- )} - - ); - })()} - - Models{" "} - - - - - } - name="models" - > - form.setFieldValue("models", values)} - organizationID={form.getFieldValue("organization_id")} - options={{ - includeSpecialOptions: true, - showAllProxyModelsOverride: !form.getFieldValue("organization_id"), - }} - context="team" - dataTestId="create-team-models-select" - /> - - - - - - - - - - - - - - - - - - - - - Additional Settings - - - - - { - e.target.value = e.target.value.trim(); - }} - /> - - (value ? Number(value) : undefined)} - tooltip="This is the individual budget for a user in the team." - > - - - - - - - - - - - - { - if (!value) { - return Promise.resolve(); - } - try { - JSON.parse(value); - return Promise.resolve(); - } catch (error) { - return Promise.reject(new Error("Please enter valid JSON")); - } - }, - }, - ]} - > - - - - Guardrails{" "} - - e.stopPropagation()} - > - - - - - } - name="guardrails" - className="mt-8" - help="Select existing guardrails or enter new ones" - > - ({ - value: name, - label: name, - }))} - /> - + + + + + {({ ref, value, ...field }) => ( + )} - - Access Groups{" "} - - - - - } - name="access_group_ids" - className="mt-8" - help="Select access groups to assign to this team" - > - - - - Allowed Vector Stores{" "} - - - - - } - name="allowed_vector_store_ids" - className="mt-8" - help="Select vector stores this team can access. Leave empty for access to all vector stores" - > - form.setFieldValue("allowed_vector_store_ids", values)} - value={form.getFieldValue("allowed_vector_store_ids")} - accessToken={accessToken || ""} - placeholder="Select vector stores (optional)" - /> - - - - - - + + {(() => { + const adminOrgs = getAdminOrganizations(userRole, userID, organizations); + const isSingleOrg = adminOrgs.length === 1; + const hasNoOrgs = adminOrgs.length === 0; - - - MCP Settings - - - - - Allowed MCP Servers{" "} - - - - - } - name="allowed_mcp_servers_and_groups" - className="mt-4" - help="Select MCP servers or access groups this team can access" - > - form.setFieldValue("allowed_mcp_servers_and_groups", val)} - value={form.getFieldValue("allowed_mcp_servers_and_groups")} - accessToken={accessToken || ""} - placeholder="Select MCP servers or access groups (optional)" - allowAllProxyMcpServers={isProxyAdminRole(userRole || "")} + return ( + <> + + {({ id, value, onChange }) => ( + ({ + value: org.organization_id ?? "", + label: org.organization_alias ?? "", + sublabel: org.organization_id ?? "", + }))} + disabled={isOrgAdmin && isSingleOrg} + allowClear={!isOrgAdmin} + placeholder={hasNoOrgs ? "No organizations available" : "Search or select an Organization"} + emptyText="No organizations available" + onValueChange={(next) => { + onChange(next === "" ? null : next); + setCurrentOrgForCreateTeam(adminOrgs.find((org) => org.organization_id === next) ?? null); + }} + /> + )} + + + {isOrgAdmin && !isSingleOrg && adminOrgs.length > 1 && ( +
+ + Please select an organization to create a team for. You can only create teams within + organizations where you are an admin. + +
+ )} + + ); + })()} + + {({ id, value, onChange }) => ( + -
+ )} + - {/* Hidden field to register mcp_tool_permissions with the form */} - + + {({ ref, value, ...field }) => ( + + )} + + + {({ id, value, onChange }) => ( + + )} + + + {({ ref, value, ...field }) => ( + + )} + + + {({ ref, value, ...field }) => ( + + )} + + + Metadata + + + Values are saved as text. Enter JSON for typed values, e.g. 3, true, or {'{"region": "us"}'}. + + - - prevValues.allowed_mcp_servers_and_groups !== currentValues.allowed_mcp_servers_and_groups || - prevValues.mcp_tool_permissions !== currentValues.mcp_tool_permissions - } - > - {() => ( -
- + + Additional Settings + + + + + + {({ ref, value, ...field }) => } + + + {({ ref, value, onChange, ...field }) => ( + ) => + onChange(event.target.value ? Number(event.target.value) : undefined) + } + step={0.01} + precision={2} + width={200} + /> + )} + + + {({ ref, value, ...field }) => ( + + )} + + + {({ ref, value, ...field }) => ( + + )} + + + {({ ref, value, ...field }) => ( + + )} + + + {({ ref, value, ...field }) => ( +