Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_/confident-varahamihira-1fe6ef

This commit is contained in:
Yuneng Jiang 2026-08-18 22:43:16 -07:00
commit 6ea5581c3a
No known key found for this signature in database
29 changed files with 4096 additions and 1287 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 #####################

View file

@ -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)

View file

@ -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,

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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"""

View file

@ -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

View file

@ -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

View file

@ -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),

View file

@ -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():
"""

View file

@ -0,0 +1,497 @@
import { describe, expect, it } from "vitest";
import {
mountedCreateFieldNames,
mountedEditFieldNames,
projectMountedCreateValues,
projectMountedEditValues,
} from "./mountedServerFields";
const editRoot = (values: Record<string, unknown>) => mountedEditFieldNames(values).root;
const editCreds = (values: Record<string, unknown>) => mountedEditFieldNames(values).credentials;
const createRoot = (values: Record<string, unknown>) => mountedCreateFieldNames(values).root;
const createCreds = (values: Record<string, unknown>) => 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<string, unknown>,
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<string, unknown>,
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<string, unknown>).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);
});
});

View file

@ -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<string, unknown>): 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<string, unknown>): 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<string, unknown> | undefined, names: readonly string[]): Record<string, unknown> =>
Object.fromEntries(names.map((name) => [name, source?.[name]]));
const projectWith =
(namesOf: (values: Record<string, unknown>) => MountedFieldNames) =>
(values: Record<string, unknown>): Record<string, unknown> => {
const names = namesOf(values);
const credentials = values.credentials as Record<string, unknown> | 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);

View file

@ -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<typeof import("@/components/networking")>();
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: () => <div data-testid="vector-store-selector" />,
}));
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(<AddModelPanel />);
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();
});
});

View file

@ -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) => {
</>
)}
</ComboboxValue>
<ComboboxChipsInput placeholder="Select Models" aria-label="Select Models" className="min-w-24" />
<ComboboxChipsInput id={id} placeholder="Select Models" aria-label="Select Models" className="min-w-24" />
</ComboboxChips>
<ComboboxContent anchor={anchor}>
<ComboboxEmpty>No models found</ComboboxEmpty>

View file

@ -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(
<Teams accessToken="test-token" userID="user-123" userRole="Admin" premiumUser={options?.premiumUser ?? false} />,
);
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<string, unknown>;
};
const wireBody = (payload: Record<string, unknown>) => JSON.parse(JSON.stringify(payload)) as Record<string, unknown>;
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(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
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();
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,83 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import CacheControlInjectionPoints, { type CacheControlInjectionPoint } from "./cache_control_settings";
const ROLE_HINT = "LiteLLM will mark all messages of this role as cacheable";
const INDEX_HINT = "(Optional) If set litellm will mark the message at this index as cacheable";
const ONE_POINT: CacheControlInjectionPoint[] = [{ location: "message" }];
const tabTo = async (user: ReturnType<typeof userEvent.setup>, name: string): Promise<void> => {
for (let step = 0; step < 8; step++) {
await user.tab();
if (document.activeElement === screen.getByRole("button", { name })) {
return;
}
}
throw new Error(`${name} is not reachable by keyboard`);
};
describe("CacheControlInjectionPoints field hints", () => {
it("explains on the Role field that the role marks every message of that role cacheable", async () => {
const user = userEvent.setup();
render(<CacheControlInjectionPoints value={ONE_POINT} onChange={vi.fn()} />);
await user.hover(screen.getByRole("button", { name: "Role help" }));
expect(await screen.findByText(ROLE_HINT)).toBeInTheDocument();
});
it("explains on the Index field that it is optional and marks that message cacheable", async () => {
const user = userEvent.setup();
render(<CacheControlInjectionPoints value={ONE_POINT} onChange={vi.fn()} />);
await user.hover(screen.getByRole("button", { name: "Index help" }));
expect(await screen.findByText(INDEX_HINT)).toBeInTheDocument();
});
it("reveals the Role hint on keyboard focus, so it is reachable without a pointer", async () => {
const user = userEvent.setup();
render(<CacheControlInjectionPoints value={ONE_POINT} onChange={vi.fn()} />);
await tabTo(user, "Role help");
expect(await screen.findByText(ROLE_HINT)).toBeInTheDocument();
});
it("reveals the Index hint on keyboard focus, so it is reachable without a pointer", async () => {
const user = userEvent.setup();
render(<CacheControlInjectionPoints value={ONE_POINT} onChange={vi.fn()} />);
await tabTo(user, "Index help");
expect(await screen.findByText(INDEX_HINT)).toBeInTheDocument();
});
it("keeps both hints behind a hover or a focus rather than rendering them inline", () => {
render(<CacheControlInjectionPoints value={ONE_POINT} onChange={vi.fn()} />);
expect(screen.queryByText(ROLE_HINT)).not.toBeInTheDocument();
expect(screen.queryByText(INDEX_HINT)).not.toBeInTheDocument();
});
it("gives every row its own pair of hints", () => {
render(
<CacheControlInjectionPoints value={[{ location: "message" }, { location: "message" }]} onChange={vi.fn()} />,
);
expect(screen.getAllByRole("button", { name: "Role help" })).toHaveLength(2);
expect(screen.getAllByRole("button", { name: "Index help" })).toHaveLength(2);
});
it("still reports a typed index as a string through onChange", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<CacheControlInjectionPoints value={ONE_POINT} onChange={onChange} />);
await user.type(screen.getByPlaceholderText("Optional"), "3");
expect(onChange).toHaveBeenLastCalledWith([{ location: "message", index: "3" }]);
});
});

View file

@ -1,9 +1,10 @@
import { Minus, Plus } from "lucide-react";
import { CircleHelp, Minus, Plus } from "lucide-react";
import React from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import NumericalInput from "../shared/numerical_input";
@ -15,6 +16,10 @@ export const CACHE_CONTROL_TOOLTIP =
export const CACHE_CONTROL_DESCRIPTION =
"Providers like Anthropic, Bedrock API require users to specify where to inject cache control checkpoints, litellm can automatically add them for you as a cost saving feature.";
export const CACHE_CONTROL_ROLE_HINT = "LiteLLM will mark all messages of this role as cacheable";
export const CACHE_CONTROL_INDEX_HINT = "(Optional) If set litellm will mark the message at this index as cacheable";
export type CacheControlRole = "user" | "system" | "assistant";
export interface CacheControlInjectionPoint {
@ -33,6 +38,28 @@ const ROLE_ITEMS = [
{ value: "assistant", label: "Assistant" },
] as const;
const LabelWithHint: React.FC<{ label: string; hint: string }> = ({ label, hint }) => (
<div className="flex items-center">
<Label>{label}</Label>
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
aria-label={`${label} help`}
className="ml-1 inline-flex cursor-help items-center rounded-sm text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
}
>
<CircleHelp aria-hidden className="size-4" />
</TooltipTrigger>
<TooltipContent className="max-w-xs whitespace-normal">{hint}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
interface CacheControlInjectionPointsProps {
value?: CacheControlInjectionPoint[];
onChange?: (points: CacheControlInjectionPoint[]) => void;
@ -72,7 +99,7 @@ const CacheControlInjectionPoints: React.FC<CacheControlInjectionPointsProps> =
</div>
<div className="w-[180px] space-y-1">
<Label>Role</Label>
<LabelWithHint label="Role" hint={CACHE_CONTROL_ROLE_HINT} />
<Select
items={ROLE_ITEMS}
value={point.role ?? null}
@ -95,7 +122,7 @@ const CacheControlInjectionPoints: React.FC<CacheControlInjectionPointsProps> =
</div>
<div className="w-[180px] space-y-1">
<Label>Index</Label>
<LabelWithHint label="Index" hint={CACHE_CONTROL_INDEX_HINT} />
<NumericalInput
type="number"
placeholder="Optional"

View file

@ -1,12 +1,14 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Form } from "antd";
import React from "react";
import { describe, expect, it, vi } from "vitest";
import { z } from "zod/v4";
import { TeamMetadataField } from "@/app/(dashboard)/hooks/teams/useTeamMetadataSchema";
import { useZodForm } from "@/lib/forms/useZodForm";
import MetadataKeyValueFields, {
MetadataPair,
metadataObjectToPairs,
metadataPairsSchema,
metadataPairsToObject,
} from "./MetadataKeyValueFields";
@ -95,13 +97,21 @@ interface HarnessProps {
schemaLoading?: boolean;
}
const harnessSchema = z.object({ metadata: metadataPairsSchema });
const Harness: React.FC<HarnessProps> = ({ onFinish, initialMetadata, schemaFields, schemaLoading }) => {
const [form] = Form.useForm();
const form = useZodForm(harnessSchema, { defaultValues: { metadata: initialMetadata ?? [] } });
return (
<Form form={form} onFinish={onFinish} initialValues={{ metadata: initialMetadata }}>
<MetadataKeyValueFields form={form} schemaFields={schemaFields} schemaLoading={schemaLoading} />
<form onSubmit={form.handleSubmit((values) => onFinish(values))}>
<MetadataKeyValueFields
control={form.control}
getValues={form.getValues}
name="metadata"
schemaFields={schemaFields}
schemaLoading={schemaLoading}
/>
<button type="submit">Save</button>
</Form>
</form>
);
};

View file

@ -1,14 +1,36 @@
import { MinusCircleOutlined, PlusOutlined } from "@ant-design/icons";
import { Button, Form, FormInstance, Input, Skeleton, Space } from "antd";
import { CircleMinus, Plus } from "lucide-react";
import React, { useEffect, useRef } from "react";
import {
useFieldArray,
type Control,
type FieldArrayPath,
type FieldPath,
type FieldValues,
type UseFormGetValues,
} from "react-hook-form";
import { z } from "zod/v4";
import { TeamMetadataField } from "@/app/(dashboard)/hooks/teams/useTeamMetadataSchema";
import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
export interface MetadataPair {
key: string;
value: string;
}
export const metadataPairsSchema = z
.array(z.object({ key: z.string().min(1, "Missing key"), value: z.string().optional() }))
.superRefine((pairs, ctx) => {
pairs.forEach((pair, index) => {
if (pair.key && pairs.filter((other) => other.key === pair.key).length > 1) {
ctx.addIssue({ code: "custom", message: "Duplicate key", path: [index, "key"] });
}
});
});
function formatMetadataValue(value: unknown): string {
if (typeof value !== "string") {
return JSON.stringify(value) ?? "";
@ -48,87 +70,82 @@ export function metadataPairsToObject(
);
}
interface MetadataKeyValueFieldsProps {
form: FormInstance;
name?: string;
interface MetadataKeyValueFieldsProps<TFieldValues extends FieldValues> {
control: Control<TFieldValues>;
getValues: UseFormGetValues<TFieldValues>;
name: FieldArrayPath<TFieldValues>;
schemaFields?: readonly TeamMetadataField[];
schemaLoading?: boolean;
}
const MetadataKeyValueFields: React.FC<MetadataKeyValueFieldsProps> = ({
form,
name = "metadata",
const MetadataKeyValueFields = <TFieldValues extends FieldValues>({
control,
getValues,
name,
schemaFields = [],
schemaLoading = false,
}) => {
}: MetadataKeyValueFieldsProps<TFieldValues>) => {
const { fields, append, remove } = useFieldArray({ control, name });
const seededRef = useRef(false);
useEffect(() => {
if (seededRef.current || schemaLoading || schemaFields.length === 0) return;
seededRef.current = true;
const pairs: (Partial<MetadataPair> | undefined)[] = form.getFieldValue(name) ?? [];
const pairs: (Partial<MetadataPair> | undefined)[] = getValues(name as unknown as FieldPath<TFieldValues>) ?? [];
if (!Array.isArray(pairs)) return;
const existingKeys = new Set(pairs.map((pair) => pair?.key).filter(Boolean));
const seeded = schemaFields
.filter((field) => !existingKeys.has(field.key))
.map((field) => ({ key: field.key, value: "" }));
if (seeded.length > 0) {
form.setFieldValue(name, [...pairs, ...seeded]);
append(seeded as never, { shouldFocus: false });
}
}, [form, name, schemaFields, schemaLoading]);
}, [append, getValues, name, schemaFields, schemaLoading]);
if (schemaLoading) {
return (
<div data-testid="metadata-schema-skeleton">
<Skeleton active title={false} paragraph={{ rows: 3 }} />
<div data-testid="metadata-schema-skeleton" className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
);
}
return (
<Form.List name={name}>
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name: fieldName, ...restField }) => (
<Space key={key} style={{ display: "flex", marginBottom: 8 }} align="baseline">
<Form.Item
{...restField}
name={[fieldName, "key"]}
rules={[
{ required: true, message: "Missing key" },
{
validator: (_, value) => {
if (!value) return Promise.resolve();
const all: (Partial<MetadataPair> | undefined)[] = form.getFieldValue(name) ?? [];
const dupes = all.filter((entry) => entry?.key === value);
if (dupes.length > 1) {
return Promise.reject(new Error("Duplicate key"));
}
return Promise.resolve();
},
},
]}
>
<Input placeholder="Key" />
</Form.Item>
<Form.Item {...restField} name={[fieldName, "value"]}>
<Input placeholder="Value" />
</Form.Item>
<MinusCircleOutlined
aria-label="Remove key-value pair"
onClick={() => remove(fieldName)}
style={{ color: "#ef4444" }}
/>
</Space>
))}
<Form.Item style={{ marginBottom: 0 }}>
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
Add Key-Value Pair
</Button>
</Form.Item>
</>
)}
</Form.List>
<>
{fields.map((field, index) => (
<div key={field.id} className="mb-2 flex items-start gap-2">
<FormField control={control} name={`${name}.${index}.key` as FieldPath<TFieldValues>}>
{({ ref, value, ...rest }) => (
<Input {...rest} ref={ref} value={(value as string) ?? ""} placeholder="Key" />
)}
</FormField>
<FormField control={control} name={`${name}.${index}.value` as FieldPath<TFieldValues>}>
{({ ref, value, ...rest }) => (
<Input {...rest} ref={ref} value={(value as string) ?? ""} placeholder="Value" />
)}
</FormField>
<Button
variant="ghost"
size="icon"
aria-label="Remove key-value pair"
className="mt-1 text-destructive"
onClick={() => remove(index)}
>
<CircleMinus className="size-4" />
</Button>
</div>
))}
<Button
variant="outline"
className="w-full border-dashed"
onClick={() => append({ key: "", value: "" } as never, { shouldFocus: false })}
>
<Plus className="size-4" />
Add Key-Value Pair
</Button>
</>
);
};

View file

@ -0,0 +1,34 @@
"use client";
import { CircleHelp } from "lucide-react";
import React from "react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
const hintIconClassName = "size-3.5 shrink-0 cursor-help text-muted-foreground";
export const labelWithHint = (label: React.ReactNode, hint: React.ReactNode): React.ReactNode => (
<>
{label}
<Tooltip>
<TooltipTrigger render={<CircleHelp className={hintIconClassName} />} />
<TooltipContent>{hint}</TooltipContent>
</Tooltip>
</>
);
export const labelWithDocsHint = (label: React.ReactNode, hint: React.ReactNode, href: string): React.ReactNode => (
<>
{label}
<Tooltip>
<TooltipTrigger
render={
<a href={href} target="_blank" rel="noopener noreferrer" onClick={(event) => event.stopPropagation()}>
<CircleHelp className={hintIconClassName} />
</a>
}
/>
<TooltipContent>{hint}</TooltipContent>
</Tooltip>
</>
);

View file

@ -0,0 +1,133 @@
"use client";
import { Globe } from "lucide-react";
import React, { useState } from "react";
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxCollection,
ComboboxContent,
ComboboxEmpty,
ComboboxGroup,
ComboboxItem,
ComboboxLabel,
ComboboxList,
ComboboxValue,
useComboboxAnchor,
} from "@/components/ui/combobox";
export interface GuardrailOption {
name: string;
disabled: boolean;
}
interface GuardrailGroup {
label: string;
icon: boolean;
items: GuardrailOption[];
}
interface GuardrailsSelectProps {
id?: string;
value: string[];
onValueChange: (value: string[]) => void;
globalGuardrails: readonly GuardrailOption[];
otherGuardrails: readonly GuardrailOption[];
globalGuardrailNames: ReadonlySet<string>;
placeholder?: string;
emptyText?: string;
}
const matchesQuery = (option: GuardrailOption, query: string): boolean =>
option.name.toLowerCase().includes(query.trim().toLowerCase());
export const GuardrailsSelect: React.FC<GuardrailsSelectProps> = ({
id,
value,
onValueChange,
globalGuardrails,
otherGuardrails,
globalGuardrailNames,
placeholder = "Select guardrails",
emptyText = "No guardrails found",
}) => {
const anchor = useComboboxAnchor();
const [query, setQuery] = useState("");
const known = [...globalGuardrails, ...otherGuardrails];
const selected = value.map((name) => known.find((option) => option.name === name) ?? { name, disabled: false });
const grouped = globalGuardrails.length > 0 && otherGuardrails.length > 0;
const groups: GuardrailGroup[] = grouped
? [
{ label: "Global", icon: true, items: [...globalGuardrails] },
{ label: "Other", icon: false, items: [...otherGuardrails] },
]
: [{ label: "", icon: false, items: known }];
return (
<Combobox
multiple
items={groups}
value={selected}
onValueChange={(next: GuardrailOption[]) => {
setQuery("");
onValueChange(next.map((option) => option.name));
}}
inputValue={query}
onInputValueChange={setQuery}
isItemEqualToValue={(option: GuardrailOption, other: GuardrailOption) => option.name === other.name}
itemToStringLabel={(option: GuardrailOption) => option.name}
filter={matchesQuery}
openOnInputClick
>
<ComboboxChips render={<div ref={anchor} />} className="min-h-8 py-1 text-sm">
<ComboboxValue>
{(chips: GuardrailOption[]) => (
<>
{chips.map((option) => (
<ComboboxChip key={option.name} aria-label={option.name}>
{globalGuardrailNames.has(option.name) && <Globe className="size-3" aria-label="Global guardrail" />}
{option.name}
</ComboboxChip>
))}
<ComboboxChipsInput id={id} placeholder={placeholder} className="min-w-24" aria-label={placeholder} />
</>
)}
</ComboboxValue>
</ComboboxChips>
<ComboboxContent anchor={anchor}>
<ComboboxEmpty>{emptyText}</ComboboxEmpty>
<ComboboxList>
{(group: GuardrailGroup) => (
<ComboboxGroup key={group.label} items={group.items}>
{group.label !== "" && (
<ComboboxLabel>
{group.icon ? <Globe className="mr-1 inline size-3" aria-hidden="true" /> : null}
{group.label}
</ComboboxLabel>
)}
<ComboboxCollection>
{(option: GuardrailOption) => (
<ComboboxItem
key={option.name}
value={option}
title={option.name}
disabled={option.disabled}
aria-label={option.name}
>
{option.name}
</ComboboxItem>
)}
</ComboboxCollection>
</ComboboxGroup>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
);
};
export default GuardrailsSelect;

View file

@ -122,7 +122,9 @@ vi.mock("@/components/common_components/ModelAliasManager", () => ({
<div>
<div data-testid="alias-editor-initial">{JSON.stringify(initialModelAliases)}</div>
<button onClick={() => onAliasUpdate({ "gpt-4o": "gpt-4" })}>Set Alias</button>
<button onClick={() => onAliasUpdate({})}>Clear Aliases</button>
<button type="button" onClick={() => onAliasUpdate({})}>
Clear Aliases
</button>
</div>
)),
}));
@ -658,7 +660,7 @@ describe("TeamInfoView", () => {
});
describe("settings and editing", () => {
const policiesFormFieldLabel = () => screen.queryByText("Policies", { selector: "span" });
const policiesFormFieldLabel = () => screen.queryByText("Policies", { selector: "label" });
it("should offer the policies field and load it for a caller with the viewPolicies capability", async () => {
const user = userEvent.setup({ delay: null });
@ -1462,8 +1464,7 @@ describe("TeamInfoView", () => {
await user.click(screen.getByLabelText(/^Guardrails/));
const listbox = await screen.findByRole("listbox", {}, { timeout: 5000 });
// eslint-disable-next-line local/no-antd-class-selectors -- antd renders group headers outside the listbox and its popup container exposes no role or accessible name
return listbox.closest(".ant-select-dropdown") as HTMLElement;
return listbox.closest('[data-slot="combobox-content"]') as HTMLElement;
};
beforeEach(() => {
@ -1660,3 +1661,310 @@ describe("TeamInfoView - which team member fields reach the update payload depen
expect(payload.object_permission).toHaveProperty("search_tools");
});
});
describe("TeamInfoView - the exact bytes the update call sends", () => {
const props = {
teamId: "123",
onUpdate: vi.fn(),
onClose: vi.fn(),
accessToken: "test-token",
is_team_admin: true,
is_proxy_admin: true,
userModels: ["gpt-4"],
editTeam: false,
};
beforeEach(seedDefaultMocks);
afterEach(() => {
vi.clearAllMocks();
});
const storedTeam = () =>
createMockTeamData({
models: ["gpt-4"],
max_budget: 100,
budget_duration: "1d",
tpm_limit: 1000,
rpm_limit: 1000,
team_member_budget_table: { max_budget: 42, budget_duration: "30d", tpm_limit: 11, rpm_limit: 22 },
default_team_member_models: ["gpt-4"],
object_permission: { search_tools: ["tool-a"], vector_stores: ["vs-1"] },
});
const openEditor = async (user: ReturnType<typeof userEvent.setup>) => {
vi.mocked(networking.teamInfoCall).mockResolvedValue(storedTeam());
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
renderWithProviders(<TeamInfoView {...props} />);
await waitFor(() => expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0));
await user.click(screen.getByRole("tab", { name: "Settings" }));
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
await screen.findByLabelText("Team Name");
};
const save = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(networking.teamUpdateCall).toHaveBeenCalled());
return vi.mocked(networking.teamUpdateCall).mock.calls[0][1] as Record<string, unknown>;
};
const wireBody = (payload: Record<string, unknown>) => JSON.parse(JSON.stringify(payload)) as Record<string, unknown>;
const alwaysSent = {
team_id: "123",
team_alias: "Test Team",
models: ["gpt-4"],
tpm_limit: 1000,
rpm_limit: 1000,
model_tpm_limit: {},
model_rpm_limit: {},
max_budget: 100,
soft_budget: null,
budget_duration: "1d",
metadata: {
allowed_passthrough_routes: [],
guardrails: [],
opted_out_global_guardrails: [],
disable_global_guardrails: false,
soft_budget_alerting_emails: [],
},
access_group_ids: [],
};
const mcpPermissions = {
mcp_servers: [],
mcp_access_groups: [],
mcp_tool_permissions: {},
mcp_toolsets: [],
vector_stores: ["vs-1"],
};
it("leaves every team member key out of the request body for an untouched save with both sections closed", async () => {
const user = userEvent.setup({ delay: null });
await openEditor(user);
const payload = await save(user);
expect(payload).toStrictEqual({
...alwaysSent,
team_member_budget_duration: undefined,
object_permission: mcpPermissions,
});
expect(wireBody(payload)).toStrictEqual({
...alwaysSent,
object_permission: mcpPermissions,
});
});
it("resends every stored value once both sections are opened", async () => {
const user = userEvent.setup({ delay: null });
await openEditor(user);
await user.click(screen.getByText("Team Member Settings"));
await screen.findByLabelText("Default Budget (USD)");
await user.click(screen.getByText("Search Tool Settings"));
await screen.findByPlaceholderText("Select search tools (optional, empty = all allowed)");
const payload = await save(user);
const expected = {
...alwaysSent,
team_member_budget_duration: "30d",
team_member_budget: 42,
team_member_tpm_limit: 11,
team_member_rpm_limit: 22,
default_team_member_models: ["gpt-4"],
object_permission: { ...mcpPermissions, search_tools: ["tool-a"] },
};
expect(payload).toStrictEqual(expected);
expect(wireBody(payload)).toStrictEqual(expected);
});
it("carries every typed value to the update payload at the type and shape antd sends today", async () => {
const user = userEvent.setup({ delay: null });
await openEditor(user);
const alias = screen.getByLabelText("Team Name");
await user.clear(alias);
await user.type(alias, "Renamed Team");
const softBudget = screen.getByLabelText("Soft Budget (USD)");
await user.clear(softBudget);
await user.type(softBudget, "9.5");
const emails = screen.getByLabelText(/Soft Budget Alerting Emails/);
await user.clear(emails);
await user.type(emails, "a@test.com, b@test.com ");
const tpm = screen.getByLabelText("Tokens per minute Limit (TPM)");
await user.clear(tpm);
await user.type(tpm, "555");
const payload = await save(user);
expect(payload.team_alias).toBe("Renamed Team");
expect(payload.soft_budget).toBe("9.5");
expect(payload.tpm_limit).toBe("555");
expect((payload.metadata as Record<string, unknown>).soft_budget_alerting_emails).toStrictEqual([
"a@test.com",
"b@test.com",
]);
});
it("builds model_tpm_limit and model_rpm_limit from the model-specific rate limit rows", async () => {
const user = userEvent.setup({ delay: null });
vi.mocked(networking.teamInfoCall).mockResolvedValue(
createMockTeamData({
models: ["gpt-4"],
max_budget: 100,
budget_duration: "1d",
tpm_limit: 1000,
rpm_limit: 1000,
object_permission: { vector_stores: ["vs-1"] },
metadata: { model_tpm_limit: { "gpt-4": 30 }, model_rpm_limit: { "gpt-4": 40 } },
}),
);
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
renderWithProviders(<TeamInfoView {...props} />);
await waitFor(() => expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0));
await user.click(screen.getByRole("tab", { name: "Settings" }));
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
await screen.findByLabelText("Team Name");
const payload = await save(user);
expect(payload.model_tpm_limit).toStrictEqual({ "gpt-4": 30 });
expect(payload.model_rpm_limit).toStrictEqual({ "gpt-4": 40 });
});
it("keeps a team member budget edited before the section is collapsed and resends it on reopen", async () => {
const user = userEvent.setup({ delay: null });
await openEditor(user);
await user.click(screen.getByText("Team Member Settings"));
const budgetInput = await screen.findByLabelText("Default Budget (USD)");
await user.clear(budgetInput);
await user.type(budgetInput, "77");
await user.click(screen.getByText("Team Member Settings"));
await waitFor(() => expect(screen.queryByLabelText("Default Budget (USD)")).not.toBeInTheDocument());
await user.click(screen.getByText("Team Member Settings"));
expect(await screen.findByLabelText("Default Budget (USD)")).toHaveValue(77);
const payload = await save(user);
expect(payload.team_member_budget).toBe(77);
});
it("sends no team member key at all when the section is collapsed again after an edit", async () => {
const user = userEvent.setup({ delay: null });
await openEditor(user);
await user.click(screen.getByText("Team Member Settings"));
const budgetInput = await screen.findByLabelText("Default Budget (USD)");
await user.clear(budgetInput);
await user.type(budgetInput, "77");
await user.click(screen.getByText("Team Member Settings"));
await waitFor(() => expect(screen.queryByLabelText("Default Budget (USD)")).not.toBeInTheDocument());
const payload = await save(user);
expect(Object.keys(wireBody(payload)).filter((key) => key.startsWith("team_member"))).toEqual([]);
expect(wireBody(payload)).not.toHaveProperty("default_team_member_models");
});
it("puts the global guardrails back on the team when the kill switch is turned off again", async () => {
const user = userEvent.setup({ delay: null });
testQueryClient.clear();
vi.mocked(networking.getGuardrailsList).mockResolvedValue({
guardrails: [
{ guardrail_name: "always-on", litellm_params: { default_on: true } },
{ guardrail_name: "opt-in", litellm_params: { default_on: false } },
],
});
vi.mocked(networking.teamInfoCall).mockResolvedValue(
createMockTeamData({
models: ["gpt-4"],
metadata: { guardrails: ["opt-in"], disable_global_guardrails: true },
}),
);
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
renderWithProviders(<TeamInfoView {...props} premiumUser={true} />);
await waitFor(() => expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0));
await user.click(screen.getByRole("tab", { name: "Settings" }));
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
await screen.findByLabelText("Team Name");
expect(screen.queryAllByLabelText("always-on")).toHaveLength(0);
await user.click(screen.getByRole("switch", { name: /Disable all global guardrails/ }));
expect(await screen.findAllByLabelText("always-on")).toHaveLength(1);
const payload = await save(user);
expect(payload.metadata).toStrictEqual(
expect.objectContaining({
guardrails: ["opt-in"],
opted_out_global_guardrails: [],
disable_global_guardrails: false,
}),
);
});
it("sends a typed model rate limit as a number", async () => {
const user = userEvent.setup({ delay: null });
vi.mocked(networking.teamInfoCall).mockResolvedValue(
createMockTeamData({ models: ["gpt-4"], metadata: { model_tpm_limit: { "gpt-4": 30 } } }),
);
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
renderWithProviders(<TeamInfoView {...props} />);
await waitFor(() => expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0));
await user.click(screen.getByRole("tab", { name: "Settings" }));
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
await screen.findByLabelText("Team Name");
const rpmInput = await screen.findByPlaceholderText("RPM Limit");
await user.clear(rpmInput);
await user.type(rpmInput, "45");
const payload = await save(user);
expect(payload.model_rpm_limit).toStrictEqual({ "gpt-4": 45 });
expect(payload.model_tpm_limit).toStrictEqual({ "gpt-4": 30 });
});
it("leaves stored policies out of the update body for a caller without the viewPolicies capability", async () => {
const user = userEvent.setup({ delay: null });
can.mockReturnValue(false);
vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData({ models: ["gpt-4"], policies: ["pci"] }));
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
renderWithProviders(<TeamInfoView {...props} />);
await waitFor(() => expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0));
await user.click(screen.getByRole("tab", { name: "Settings" }));
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
await screen.findByLabelText("Team Name");
const payload = await save(user);
expect(payload).toHaveProperty("team_alias");
expect(wireBody(payload)).not.toHaveProperty("policies");
});
it("blocks the save on an empty team name and names the rule", async () => {
const user = userEvent.setup({ delay: null });
await openEditor(user);
await user.clear(screen.getByLabelText("Team Name"));
await user.click(screen.getByRole("button", { name: /save changes/i }));
expect(await screen.findByText("Please input a team name")).toBeInTheDocument();
expect(networking.teamUpdateCall).not.toHaveBeenCalled();
});
});

File diff suppressed because it is too large Load diff