mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_/confident-varahamihira-1fe6ef
This commit is contained in:
commit
6ea5581c3a
29 changed files with 4096 additions and 1287 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 #####################
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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" }]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
133
ui/litellm-dashboard/src/components/team/GuardrailsSelect.tsx
Normal file
133
ui/litellm-dashboard/src/components/team/GuardrailsSelect.tsx
Normal 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;
|
||||
|
|
@ -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
Loading…
Add table
Reference in a new issue