mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-15 23:31:29 +00:00
Merge branch 'litellm_internal_staging' into litellm_add_more_tests
This commit is contained in:
commit
43dd4306eb
139 changed files with 10045 additions and 2893 deletions
2
.github/CODEOWNERS
vendored
Normal file
2
.github/CODEOWNERS
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/ui/ @yuneng-jiang @ryan-crabbe-berri
|
||||
/litellm/proxy/_experimental/out/ @yuneng-jiang @ryan-crabbe-berri
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "LiteLLM_DeletedVerificationToken" ADD COLUMN "key_type" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "LiteLLM_VerificationToken" ADD COLUMN "key_type" TEXT;
|
||||
|
||||
|
|
@ -422,6 +422,7 @@ model LiteLLM_VerificationToken {
|
|||
budget_reset_at DateTime?
|
||||
allowed_cache_controls String[] @default([])
|
||||
allowed_routes String[] @default([])
|
||||
key_type String?
|
||||
policies String[] @default([])
|
||||
access_group_ids String[] @default([])
|
||||
model_spend Json @default("{}")
|
||||
|
|
@ -516,6 +517,7 @@ model LiteLLM_DeletedVerificationToken {
|
|||
budget_reset_at DateTime?
|
||||
allowed_cache_controls String[] @default([])
|
||||
allowed_routes String[] @default([])
|
||||
key_type String?
|
||||
policies String[] @default([])
|
||||
access_group_ids String[] @default([])
|
||||
model_spend Json @default("{}")
|
||||
|
|
|
|||
|
|
@ -239,6 +239,18 @@ class PrometheusLogger(CustomLogger):
|
|||
labelnames=self.get_labels_for_metric("litellm_output_audio_tokens_metric"),
|
||||
)
|
||||
|
||||
self.litellm_video_duration_seconds_metric = self._counter_factory(
|
||||
"litellm_video_duration_seconds_metric",
|
||||
"Seconds of video generated, from usage.duration_seconds on video generation calls",
|
||||
labelnames=self.get_labels_for_metric("litellm_video_duration_seconds_metric"),
|
||||
)
|
||||
|
||||
self.litellm_images_generated_metric = self._counter_factory(
|
||||
"litellm_images_generated_metric",
|
||||
"Number of images generated, from the image generation response",
|
||||
labelnames=self.get_labels_for_metric("litellm_images_generated_metric"),
|
||||
)
|
||||
|
||||
# Remaining Budget for Team
|
||||
self.litellm_remaining_team_budget_metric = self._gauge_factory(
|
||||
"litellm_remaining_team_budget_metric",
|
||||
|
|
@ -1336,6 +1348,12 @@ class PrometheusLogger(CustomLogger):
|
|||
label_context=label_context,
|
||||
)
|
||||
|
||||
self._increment_media_generation_metrics(
|
||||
standard_logging_payload=standard_logging_payload,
|
||||
enum_values=enum_values,
|
||||
label_context=label_context,
|
||||
)
|
||||
|
||||
# MCP tool call metrics
|
||||
self._increment_mcp_tool_call_metrics(
|
||||
standard_logging_payload=standard_logging_payload,
|
||||
|
|
@ -1459,8 +1477,65 @@ class PrometheusLogger(CustomLogger):
|
|||
),
|
||||
]
|
||||
|
||||
for counter, metric_name, value in detail_metrics:
|
||||
if not isinstance(value, (int, float)) or value <= 0:
|
||||
PrometheusLogger._inc_sparse_usage_counters(
|
||||
self,
|
||||
detail_metrics,
|
||||
enum_values=enum_values,
|
||||
label_context=label_context,
|
||||
)
|
||||
|
||||
def _increment_media_generation_metrics(
|
||||
self,
|
||||
standard_logging_payload: StandardLoggingPayload,
|
||||
enum_values: UserAPIKeyLabelValues,
|
||||
label_context: PrometheusLabelFactoryContext | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Increment video-seconds and images-generated counters from
|
||||
``standard_logging_payload["metadata"]["usage_object"]``. Video
|
||||
providers report ``duration_seconds`` there; image generation calls
|
||||
report ``output_image_count``. Both are sparse: only emitted when the
|
||||
value is present and > 0, so token-only call types are unaffected.
|
||||
"""
|
||||
metadata = standard_logging_payload.get("metadata") or {}
|
||||
usage_object = metadata.get("usage_object") if isinstance(metadata, dict) else None
|
||||
if not isinstance(usage_object, dict):
|
||||
return
|
||||
|
||||
media_metrics: list[tuple[Any, DEFINED_PROMETHEUS_METRICS, Any]] = [
|
||||
(
|
||||
self.litellm_video_duration_seconds_metric,
|
||||
"litellm_video_duration_seconds_metric",
|
||||
usage_object.get("duration_seconds"),
|
||||
),
|
||||
(
|
||||
self.litellm_images_generated_metric,
|
||||
"litellm_images_generated_metric",
|
||||
usage_object.get("output_image_count"),
|
||||
),
|
||||
]
|
||||
|
||||
PrometheusLogger._inc_sparse_usage_counters(
|
||||
self,
|
||||
media_metrics,
|
||||
enum_values=enum_values,
|
||||
label_context=label_context,
|
||||
)
|
||||
|
||||
def _inc_sparse_usage_counters(
|
||||
self,
|
||||
counters_with_values: list[tuple[Any, DEFINED_PROMETHEUS_METRICS, Any]],
|
||||
enum_values: UserAPIKeyLabelValues,
|
||||
label_context: PrometheusLabelFactoryContext | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Increment each ``(counter, metric_name, value)`` entry whose value is
|
||||
a positive number. Non-numeric values (including booleans from
|
||||
malformed provider usage dicts) and values <= 0 are skipped, keeping
|
||||
scrape output sparse.
|
||||
"""
|
||||
for counter, metric_name, value in counters_with_values:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)) or value <= 0:
|
||||
continue
|
||||
PrometheusLogger._inc_labeled_counter(
|
||||
self,
|
||||
|
|
@ -1716,6 +1791,35 @@ class PrometheusLogger(CustomLogger):
|
|||
amount=float(response_cost),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_remaining_from_v3_rate_limit_headers(
|
||||
standard_logging_payload: StandardLoggingPayload | None,
|
||||
rate_limit_type: Literal["requests", "tokens"],
|
||||
) -> int | None:
|
||||
"""
|
||||
Read the per-(key, model) remaining value emitted by the v3 rate
|
||||
limiter (``parallel_request_limiter_v3.py``), which writes
|
||||
``x-ratelimit-model_per_key-remaining-{requests,tokens}`` into
|
||||
``standard_logging_object.hidden_params.additional_headers`` instead
|
||||
of the ``litellm-key-remaining-*`` metadata keys the legacy limiter
|
||||
sets. The header carries no model group; it always refers to this
|
||||
request's model group, which is what the gauges are labeled with.
|
||||
Values are written in-process as plain ints (never HTTP-serialized
|
||||
strings), so anything else is rejected rather than coerced.
|
||||
"""
|
||||
if standard_logging_payload is None:
|
||||
return None
|
||||
hidden_params = standard_logging_payload.get("hidden_params")
|
||||
if hidden_params is None:
|
||||
return None
|
||||
additional_headers = hidden_params.get("additional_headers")
|
||||
if additional_headers is None:
|
||||
return None
|
||||
value = dict(additional_headers).get(f"x-ratelimit-model_per_key-remaining-{rate_limit_type}")
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
return None
|
||||
return value
|
||||
|
||||
def _set_virtual_key_rate_limit_metrics(
|
||||
self,
|
||||
user_api_key: Optional[str],
|
||||
|
|
@ -1733,11 +1837,20 @@ class PrometheusLogger(CustomLogger):
|
|||
model_group = get_model_group_from_litellm_kwargs(kwargs)
|
||||
remaining_requests_variable_name = f"litellm-key-remaining-requests-{model_group}"
|
||||
remaining_tokens_variable_name = f"litellm-key-remaining-tokens-{model_group}"
|
||||
standard_logging_payload: StandardLoggingPayload | None = kwargs.get("standard_logging_object")
|
||||
|
||||
remaining_requests = metadata.get(remaining_requests_variable_name)
|
||||
if remaining_requests is None:
|
||||
remaining_requests = self._get_remaining_from_v3_rate_limit_headers(
|
||||
standard_logging_payload=standard_logging_payload, rate_limit_type="requests"
|
||||
)
|
||||
if remaining_requests is None:
|
||||
remaining_requests = sys.maxsize
|
||||
remaining_tokens = metadata.get(remaining_tokens_variable_name)
|
||||
if remaining_tokens is None:
|
||||
remaining_tokens = self._get_remaining_from_v3_rate_limit_headers(
|
||||
standard_logging_payload=standard_logging_payload, rate_limit_type="tokens"
|
||||
)
|
||||
if remaining_tokens is None:
|
||||
remaining_tokens = sys.maxsize
|
||||
|
||||
|
|
|
|||
|
|
@ -2,26 +2,8 @@ from typing import Optional
|
|||
|
||||
from litellm.llms.openai.data_residency import infer_openai_data_residency
|
||||
|
||||
# Pre-define optional kwargs keys as frozenset for O(1) lookups
|
||||
# These are extracted from kwargs only if present, avoiding unnecessary .get() calls
|
||||
OPTIONAL_KWARGS_KEYS = frozenset(
|
||||
AWS_CREDENTIAL_KWARGS_KEYS = frozenset(
|
||||
{
|
||||
"azure_ad_token",
|
||||
"tenant_id",
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"azure_username",
|
||||
"azure_password",
|
||||
"azure_scope",
|
||||
"timeout",
|
||||
"gcs_bucket_name",
|
||||
"bucket_name",
|
||||
"vertex_credentials",
|
||||
"vertex_project",
|
||||
"vertex_location",
|
||||
"vertex_ai_project",
|
||||
"vertex_ai_location",
|
||||
"vertex_ai_credentials",
|
||||
"aws_region_name",
|
||||
"aws_access_key_id",
|
||||
"aws_secret_access_key",
|
||||
|
|
@ -34,14 +16,40 @@ OPTIONAL_KWARGS_KEYS = frozenset(
|
|||
"aws_external_id",
|
||||
"aws_bedrock_runtime_endpoint",
|
||||
"aws_bedrock_project_id",
|
||||
"tpm",
|
||||
"rpm",
|
||||
"itpm",
|
||||
"otpm",
|
||||
"use_xai_oauth",
|
||||
}
|
||||
)
|
||||
|
||||
# Pre-define optional kwargs keys as frozenset for O(1) lookups
|
||||
# These are extracted from kwargs only if present, avoiding unnecessary .get() calls
|
||||
OPTIONAL_KWARGS_KEYS = (
|
||||
frozenset(
|
||||
{
|
||||
"azure_ad_token",
|
||||
"tenant_id",
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"azure_username",
|
||||
"azure_password",
|
||||
"azure_scope",
|
||||
"timeout",
|
||||
"gcs_bucket_name",
|
||||
"bucket_name",
|
||||
"vertex_credentials",
|
||||
"vertex_project",
|
||||
"vertex_location",
|
||||
"vertex_ai_project",
|
||||
"vertex_ai_location",
|
||||
"vertex_ai_credentials",
|
||||
"tpm",
|
||||
"rpm",
|
||||
"itpm",
|
||||
"otpm",
|
||||
"use_xai_oauth",
|
||||
}
|
||||
)
|
||||
| AWS_CREDENTIAL_KWARGS_KEYS
|
||||
)
|
||||
|
||||
# Backward-compatible alias for existing imports/tests.
|
||||
_OPTIONAL_KWARGS_KEYS = OPTIONAL_KWARGS_KEYS
|
||||
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ from litellm.litellm_core_utils.model_param_helper import ModelParamHelper
|
|||
from litellm.litellm_core_utils.redact_messages import (
|
||||
redact_message_input_output_from_custom_logger,
|
||||
redact_message_input_output_from_logging,
|
||||
redact_streaming_responses_for_custom_logger,
|
||||
)
|
||||
from litellm.llms.base_llm.ocr.transformation import OCRResponse
|
||||
from litellm.llms.base_llm.search.transformation import SearchResponse
|
||||
|
|
@ -2576,6 +2577,9 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
model_call_details = callback.redact_standard_logging_payload_from_model_call_details(
|
||||
model_call_details=model_call_details
|
||||
)
|
||||
model_call_details = redact_streaming_responses_for_custom_logger(
|
||||
model_call_details=model_call_details, custom_logger=callback
|
||||
)
|
||||
##################################
|
||||
if self.stream is True:
|
||||
if "async_complete_streaming_response" in model_call_details:
|
||||
|
|
@ -5208,10 +5212,15 @@ def get_standard_logging_object_payload(
|
|||
call_type = kwargs.get("call_type")
|
||||
cache_hit = kwargs.get("cache_hit", False)
|
||||
# Extract usage as a plain dict, avoiding Pydantic round-trip
|
||||
usage_dict = StandardLoggingPayloadSetup.get_usage_as_dict(
|
||||
raw_usage_dict = StandardLoggingPayloadSetup.get_usage_as_dict(
|
||||
response_obj=response_obj,
|
||||
combined_usage_object=cast(Optional[Usage], kwargs.get("combined_usage_object")),
|
||||
)
|
||||
usage_dict = (
|
||||
{**raw_usage_dict, "output_image_count": len(init_response_obj.data)}
|
||||
if isinstance(init_response_obj, ImageResponse) and init_response_obj.data
|
||||
else raw_usage_dict
|
||||
)
|
||||
|
||||
id = response_obj.get("id", kwargs.get("litellm_call_id"))
|
||||
|
||||
|
|
|
|||
|
|
@ -38,10 +38,45 @@ def redact_message_input_output_from_custom_logger(
|
|||
litellm_logging_obj: LiteLLMLoggingObject, result, custom_logger: CustomLogger
|
||||
):
|
||||
if hasattr(custom_logger, "message_logging") and custom_logger.message_logging is not True:
|
||||
return perform_redaction(litellm_logging_obj.model_call_details, result)
|
||||
return perform_redaction(litellm_logging_obj.model_call_details, result, redact_streaming_responses=False)
|
||||
return result
|
||||
|
||||
|
||||
def redact_streaming_responses_for_custom_logger(model_call_details: dict, custom_logger: CustomLogger) -> dict:
|
||||
"""
|
||||
Returns a copy of model_call_details whose streaming response entries are redacted deepcopies
|
||||
when the custom logger has opted out of message logging. The shared model_call_details is left
|
||||
untouched so other callbacks still receive the unredacted response.
|
||||
"""
|
||||
if not (hasattr(custom_logger, "message_logging") and custom_logger.message_logging is not True):
|
||||
return model_call_details
|
||||
redacted_entries = {
|
||||
streaming_key: _redacted_streaming_response_copy(model_call_details[streaming_key])
|
||||
for streaming_key in ("complete_streaming_response", "async_complete_streaming_response")
|
||||
if model_call_details.get(streaming_key) is not None
|
||||
}
|
||||
if not redacted_entries:
|
||||
return model_call_details
|
||||
return {**model_call_details, **redacted_entries}
|
||||
|
||||
|
||||
def _redacted_streaming_response_copy(streaming_response):
|
||||
redacted_response = copy.deepcopy(streaming_response)
|
||||
_redact_streaming_response(redacted_response)
|
||||
return redacted_response
|
||||
|
||||
|
||||
def _redact_streaming_response(streaming_response):
|
||||
if hasattr(streaming_response, "choices"):
|
||||
for choice in streaming_response.choices:
|
||||
_redact_choice_content(choice)
|
||||
redact_vertex_ai_metadata_from_logged_object(streaming_response)
|
||||
elif hasattr(streaming_response, "output"):
|
||||
_redact_responses_api_output(streaming_response.output)
|
||||
if hasattr(streaming_response, "reasoning") and streaming_response.reasoning is not None:
|
||||
streaming_response.reasoning = None
|
||||
|
||||
|
||||
def _redact_choice_content(choice):
|
||||
"""Helper to redact content in a choice (message or delta)."""
|
||||
if isinstance(choice, litellm.Choices):
|
||||
|
|
@ -150,9 +185,13 @@ def _redact_model_response_dict_choices(choices, redacted_str: str):
|
|||
_redact_choice_content(choice)
|
||||
|
||||
|
||||
def perform_redaction(model_call_details: dict, result):
|
||||
def perform_redaction(model_call_details: dict, result, redact_streaming_responses: bool = True):
|
||||
"""
|
||||
Performs the actual redaction on the logging object and result.
|
||||
|
||||
redact_streaming_responses=False skips the in-place redaction of the shared streaming
|
||||
response entries; per-callback redaction hands each opted-out callback its own redacted
|
||||
copy via redact_streaming_responses_for_custom_logger instead.
|
||||
"""
|
||||
# Redact model_call_details
|
||||
model_call_details["messages"] = [{"role": "user", "content": "redacted-by-litellm"}]
|
||||
|
|
@ -162,17 +201,9 @@ def perform_redaction(model_call_details: dict, result):
|
|||
redact_vertex_ai_metadata_from_litellm_params(model_call_details)
|
||||
|
||||
# Redact streaming response
|
||||
if model_call_details.get("stream", False) is True and "complete_streaming_response" in model_call_details:
|
||||
_streaming_response = model_call_details["complete_streaming_response"]
|
||||
if hasattr(_streaming_response, "choices"):
|
||||
for choice in _streaming_response.choices:
|
||||
_redact_choice_content(choice)
|
||||
redact_vertex_ai_metadata_from_logged_object(_streaming_response)
|
||||
elif hasattr(_streaming_response, "output"):
|
||||
_redact_responses_api_output(_streaming_response.output)
|
||||
# Redact reasoning field in ResponsesAPIResponse
|
||||
if hasattr(_streaming_response, "reasoning") and _streaming_response.reasoning is not None:
|
||||
_streaming_response.reasoning = None
|
||||
if redact_streaming_responses and model_call_details.get("stream", False) is True:
|
||||
for _streaming_key in ("complete_streaming_response", "async_complete_streaming_response"):
|
||||
_redact_streaming_response(model_call_details.get(_streaming_key))
|
||||
|
||||
# Redact result
|
||||
if result is not None:
|
||||
|
|
|
|||
|
|
@ -198,14 +198,16 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
|
|||
@staticmethod
|
||||
def translate_tool_choice_to_responses_api(
|
||||
tool_choice: AnthropicMessagesToolChoice,
|
||||
) -> Dict[str, Any]:
|
||||
) -> Union[str, dict[str, Any]]:
|
||||
"""Convert Anthropic tool_choice to Responses API tool_choice."""
|
||||
tc_type = tool_choice.get("type")
|
||||
if tc_type == "any":
|
||||
return {"type": "required"}
|
||||
return "required"
|
||||
elif tc_type == "tool":
|
||||
return {"type": "function", "name": tool_choice.get("name", "")}
|
||||
return {"type": "auto"}
|
||||
elif tc_type == "none":
|
||||
return "none"
|
||||
return "auto"
|
||||
|
||||
@staticmethod
|
||||
def translate_context_management_to_responses_api(
|
||||
|
|
|
|||
|
|
@ -877,6 +877,15 @@ class BaseAWSLLM:
|
|||
"Resource": "*",
|
||||
"Condition": {"Bool": {"aws:SecureTransport": "true"}},
|
||||
},
|
||||
{
|
||||
"Sid": "BedrockMantleLiteLLM",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"bedrock-mantle:CreateInference",
|
||||
],
|
||||
"Resource": "*",
|
||||
"Condition": {"Bool": {"aws:SecureTransport": "true"}},
|
||||
},
|
||||
],
|
||||
}
|
||||
assume_role_params = {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ from litellm.types.utils import LlmProviders
|
|||
|
||||
from ..common_utils import OpenAIError
|
||||
|
||||
OPENAI_RESPONSES_API_MIN_MAX_OUTPUT_TOKENS = 16
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
|
||||
|
||||
|
|
@ -59,6 +61,19 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
|
|||
key="supports_none_reasoning_effort",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _enforce_min_max_output_tokens(max_output_tokens: "int | None") -> "int | None":
|
||||
"""Raise sub-minimum max_output_tokens up to the OpenAI Responses API minimum.
|
||||
|
||||
OpenAI's Responses API rejects max_output_tokens below 16 for every model
|
||||
(not gpt-5 specific), so a client like Claude Code that sends a max_tokens=1
|
||||
warmup probe on model switch would otherwise 400. Values that are None or
|
||||
already at/above the minimum are returned unchanged.
|
||||
"""
|
||||
if isinstance(max_output_tokens, int) and max_output_tokens < OPENAI_RESPONSES_API_MIN_MAX_OUTPUT_TOKENS:
|
||||
return OPENAI_RESPONSES_API_MIN_MAX_OUTPUT_TOKENS
|
||||
return max_output_tokens
|
||||
|
||||
def get_supported_openai_params(self, model: str) -> list:
|
||||
"""
|
||||
All OpenAI Responses API params are supported
|
||||
|
|
@ -92,6 +107,9 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
|
|||
"""
|
||||
params = dict(response_api_optional_params)
|
||||
|
||||
if "max_output_tokens" in params:
|
||||
params["max_output_tokens"] = self._enforce_min_max_output_tokens(params.get("max_output_tokens"))
|
||||
|
||||
if self._is_gpt_5_model(model=model):
|
||||
temperature = params.get("temperature")
|
||||
if temperature is not None and temperature != 1:
|
||||
|
|
|
|||
|
|
@ -92,7 +92,10 @@ from litellm.litellm_core_utils.completion_timeout import CompletionTimeout
|
|||
from litellm.litellm_core_utils.request_timeout_resolver import (
|
||||
get_configured_request_timeout,
|
||||
)
|
||||
from litellm.litellm_core_utils.get_litellm_params import OPTIONAL_KWARGS_KEYS
|
||||
from litellm.litellm_core_utils.get_litellm_params import (
|
||||
AWS_CREDENTIAL_KWARGS_KEYS,
|
||||
OPTIONAL_KWARGS_KEYS,
|
||||
)
|
||||
from litellm.litellm_core_utils.dd_tracing import tracer
|
||||
from litellm.litellm_core_utils.get_provider_specific_headers import (
|
||||
ProviderSpecificHeaderUtils,
|
||||
|
|
@ -5322,7 +5325,7 @@ def completion( # type: ignore
|
|||
tpm=kwargs.get("tpm"),
|
||||
rpm=kwargs.get("rpm"),
|
||||
use_xai_oauth=kwargs.get("use_xai_oauth", False),
|
||||
aws_bedrock_project_id=kwargs.get("aws_bedrock_project_id"),
|
||||
**{key: kwargs[key] for key in AWS_CREDENTIAL_KWARGS_KEYS if key in kwargs},
|
||||
)
|
||||
cast(LiteLLMLoggingObj, logging).update_environment_variables(
|
||||
model=model,
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ class LiteLLM_VerificationToken(LiteLLMPydanticObjectBase):
|
|||
budget_reset_at: Optional[datetime] = None
|
||||
allowed_cache_controls: Optional[list] = []
|
||||
allowed_routes: Optional[list] = []
|
||||
key_type: str | None = None
|
||||
permissions: Dict = {}
|
||||
model_spend: Dict = {}
|
||||
model_max_budget: Dict = {}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credenti
|
|||
is_bridge_envelope_shaped,
|
||||
resolve_bridge_envelope,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import (
|
||||
EnvelopeIdentity,
|
||||
)
|
||||
from litellm.proxy._types import (
|
||||
UI_TEAM_ID,
|
||||
LiteLLM_TeamTable,
|
||||
|
|
@ -543,7 +546,7 @@ class MCPRequestHandler:
|
|||
header_key = server.alias or server.server_name
|
||||
if header_key is None:
|
||||
raise HTTPException(status_code=500, detail="Server misconfigured: MCP server has no routable name")
|
||||
admitted = await MCPRequestHandler._reload_admitted_key(result.identity.key_hash)
|
||||
admitted = await MCPRequestHandler._reload_admitted_principal(result.identity)
|
||||
await MCPRequestHandler._enforce_admitted_live_policy(admitted=admitted, request=request, route=route)
|
||||
injected = {header_key: {"Authorization": result.upstream_authorization.get_secret_value()}}
|
||||
new_headers = {**(mcp_server_auth_headers or {}), **injected}
|
||||
|
|
@ -572,6 +575,89 @@ class MCPRequestHandler:
|
|||
route=route,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _reload_admitted_principal(identity: EnvelopeIdentity) -> UserAPIKeyAuth:
|
||||
"""Reload the live litellm record the envelope's subject references.
|
||||
|
||||
Dispatches on the sealed subject type: a ``key_hash`` reloads the virtual key that
|
||||
minted the envelope (the scripted two-header client that presents a litellm key at the
|
||||
token endpoint), a ``user_id`` reloads the user that authenticated interactively (the
|
||||
DCR client, whose SSO login at the bridged authorize yields a user, not a key). Both
|
||||
return a ``UserAPIKeyAuth`` the caller runs through the centralized policy gate, so
|
||||
team/project/org/budget/SCIM enforcement is identical to the principal presenting
|
||||
itself directly."""
|
||||
match identity.subject_type:
|
||||
case "key_hash":
|
||||
return await MCPRequestHandler._reload_admitted_key(identity.subject)
|
||||
case "user_id":
|
||||
return await MCPRequestHandler._reload_admitted_user(identity.subject)
|
||||
case _:
|
||||
assert_never(identity.subject_type)
|
||||
|
||||
@staticmethod
|
||||
async def _reload_admitted_user(user_id: str) -> UserAPIKeyAuth:
|
||||
"""Reload the live user an interactively-minted envelope references and admit them as
|
||||
themselves.
|
||||
|
||||
The DCR client authenticates via SSO at the bridged authorize, which yields a user
|
||||
subject rather than a virtual key, so the envelope admits under the user's own
|
||||
identity: the reloaded ``user_id`` and the user's own MCP object permission ride on the
|
||||
returned ``UserAPIKeyAuth``, and the SAME ``get_allowed_mcp_servers`` the key path uses then
|
||||
computes which servers the user may reach, so the user's litellm MCP grants and access groups
|
||||
gate the request exactly as a key's do. Only the user's OWN object permission is bound: a
|
||||
``UserAPIKeyAuth`` carries a single ``team_id`` while a user may belong to many teams, so
|
||||
team-inherited MCP grants for a user are a follow-up (they need a many-teams union
|
||||
``get_allowed_mcp_servers`` does not do off one auth object). The caller's centralized policy
|
||||
gate enforces the user's live budget and org state, and a SCIM-deactivated owner fails closed.
|
||||
|
||||
Error handling mirrors the key path's retryable-503 contract, but ``get_user_object`` defeats a
|
||||
type-based check: where ``get_key_object`` raises a typed ``ProxyException`` for a missing key
|
||||
and lets a DB outage propagate raw, ``get_user_object`` catches every DB failure and re-raises a
|
||||
bare ``ValueError``, so a missing user and a real outage look identical and the original error
|
||||
survives only as ``__context__``. ``_raise_503_if_db_unavailable`` therefore walks the cause
|
||||
chain: a transient DB outage still surfaces as a retryable 503, while a missing user, or any
|
||||
other non-outage resolution failure, fails closed as a 401 rather than an opaque 500. The
|
||||
object-permission load shares this one boundary, so an outage there is classified the same
|
||||
way (``get_object_permission`` itself swallows a failed load to ``None``, matching how
|
||||
``get_key_object`` best-effort-loads a key's object permission)."""
|
||||
from litellm.proxy.auth.auth_checks import get_object_permission, get_user_object
|
||||
from litellm.proxy.proxy_server import prisma_client, user_api_key_cache
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(status_code=500, detail="Server misconfigured: no database connection")
|
||||
try:
|
||||
user_object = await get_user_object(
|
||||
user_id=user_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
user_id_upsert=False,
|
||||
)
|
||||
# Resolve the user's own MCP object permission (get_user_object does not load it) so the shared
|
||||
# get_allowed_mcp_servers can grant the user their litellm-granted servers. Reuses the same
|
||||
# get_object_permission resolver the key and team paths use; no permission logic is duplicated.
|
||||
object_permission = user_object.object_permission if user_object is not None else None
|
||||
if user_object is not None and object_permission is None and user_object.object_permission_id:
|
||||
object_permission = await get_object_permission(
|
||||
object_permission_id=user_object.object_permission_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
)
|
||||
except (ProxyException, HTTPException):
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired credential") from None
|
||||
except Exception as e: # noqa: BLE001 # a DB outage anywhere in the resolution is a retryable 503, not an opaque 500; anything else fails closed as 401
|
||||
MCPRequestHandler._raise_503_if_db_unavailable(e)
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired credential") from None
|
||||
if user_object is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired credential")
|
||||
if isinstance(user_object.metadata, dict) and user_object.metadata.get("scim_active") is False:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired credential")
|
||||
return UserAPIKeyAuth(
|
||||
user_id=user_object.user_id,
|
||||
user_role=user_object.user_role,
|
||||
object_permission=object_permission,
|
||||
object_permission_id=user_object.object_permission_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _reload_admitted_key(key_hash: str) -> UserAPIKeyAuth:
|
||||
"""Reload the live key record an admitted envelope references and re-check live policy.
|
||||
|
|
@ -615,10 +701,14 @@ class MCPRequestHandler:
|
|||
"""Raise a retryable 503 when ``e`` means the auth database is unreachable, else return so the
|
||||
caller applies its own fail-closed mapping. A DB outage must not masquerade as an auth failure
|
||||
(401) or surface as an opaque 500; the caller retries. Mirrors ``UserAPIKeyAuthExceptionHandler``,
|
||||
which renders a service-unavailable database error as 503 on the standard pipeline."""
|
||||
which renders a service-unavailable database error as 503 on the standard pipeline.
|
||||
|
||||
Classifies across the ``__cause__``/``__context__`` chain, not just ``e`` itself: ``get_user_object``
|
||||
re-raises every DB failure as a bare ``ValueError``, so a type-based check on the top exception
|
||||
would miss a real outage wrapped inside it."""
|
||||
from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler
|
||||
|
||||
if PrismaDBExceptionHandler.is_database_service_unavailable_error(e):
|
||||
if PrismaDBExceptionHandler.is_database_service_unavailable_error_in_chain(e):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Service Unavailable, the authentication database is temporarily unreachable. Please retry shortly.",
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
|
|||
import httpx
|
||||
from fastapi import APIRouter, Form, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
|
||||
from pydantic import BaseModel, SecretStr, ValidationError
|
||||
from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationError
|
||||
from typing_extensions import assert_never
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
|
|
@ -24,6 +24,15 @@ from litellm.proxy._experimental.mcp_server.auth.token_endpoint_auth import (
|
|||
TokenEndpointAuthConfigError,
|
||||
build_token_endpoint_client_auth,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.faults import (
|
||||
CallerRejected,
|
||||
CredentialSource,
|
||||
UpstreamProtocolFault,
|
||||
classify_upstream_dcr_rejection,
|
||||
classify_upstream_token_rejection,
|
||||
dcr_fault_detail,
|
||||
render_token_fault,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.oauth_utils import (
|
||||
TOKEN_NO_CACHE_HEADERS,
|
||||
get_request_base_url,
|
||||
|
|
@ -41,7 +50,9 @@ from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import (
|
||||
EnvelopeIdentity,
|
||||
EnvelopeKeys,
|
||||
RefreshCredential,
|
||||
UpstreamTokenGrant,
|
||||
)
|
||||
from litellm.proxy._types import LiteLLM_MCPServerTable, UserAPIKeyAuth
|
||||
|
|
@ -98,6 +109,8 @@ def encode_state_with_base_url(
|
|||
code_challenge: Optional[str] = None,
|
||||
code_challenge_method: Optional[str] = None,
|
||||
client_redirect_uri: Optional[str] = None,
|
||||
litellm_user_id: str | None = None,
|
||||
mcp_server_id: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Encode the base_url, original state, and PKCE parameters using encryption.
|
||||
|
|
@ -108,6 +121,11 @@ def encode_state_with_base_url(
|
|||
code_challenge: PKCE code challenge from client
|
||||
code_challenge_method: PKCE code challenge method from client
|
||||
client_redirect_uri: Original redirect_uri from client
|
||||
litellm_user_id: The SSO-authenticated litellm user captured at the bridge authorize
|
||||
(interactive dcr_bridge oauth_delegate only); the callback seals it into the gateway
|
||||
authorization code so the token mint can bind the envelope to this user
|
||||
mcp_server_id: The bridge server the interactive flow targets, sealed alongside
|
||||
litellm_user_id so the gateway code cannot be replayed against another server
|
||||
|
||||
Returns:
|
||||
An encrypted string that encodes all values
|
||||
|
|
@ -118,6 +136,8 @@ def encode_state_with_base_url(
|
|||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": code_challenge_method,
|
||||
"client_redirect_uri": client_redirect_uri,
|
||||
"litellm_user_id": litellm_user_id,
|
||||
"mcp_server_id": mcp_server_id,
|
||||
}
|
||||
state_json = json.dumps(state_data, sort_keys=True)
|
||||
encrypted_state = encrypt_value_helper(state_json)
|
||||
|
|
@ -145,6 +165,68 @@ def decode_state_hash(encrypted_state: str) -> dict:
|
|||
return state_data
|
||||
|
||||
|
||||
_BRIDGE_AUTH_CODE_PREFIX = "llm_bcode_"
|
||||
|
||||
|
||||
class _BridgeAuthorizationCode(BaseModel):
|
||||
"""The identity and upstream code the gateway seals into the authorization code it hands a DCR
|
||||
client for an interactive dcr_bridge oauth_delegate sign-in, recovered at the token endpoint."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
upstream_code: str = Field(min_length=1)
|
||||
litellm_user_id: str = Field(min_length=1)
|
||||
mcp_server_id: str = Field(min_length=1)
|
||||
|
||||
|
||||
def is_bridge_authorization_code(code: str) -> bool:
|
||||
"""Cheap prefix check that ``code`` is a gateway-sealed bridge authorization code rather than a
|
||||
raw upstream code, so the token endpoint can route without decrypting."""
|
||||
return code.startswith(_BRIDGE_AUTH_CODE_PREFIX)
|
||||
|
||||
|
||||
def seal_bridge_authorization_code(upstream_code: str, litellm_user_id: str, mcp_server_id: str) -> str:
|
||||
"""Seal the upstream authorization code and the SSO-captured litellm user into a gateway
|
||||
authorization code. The DCR client only echoes this opaque value back at the token endpoint; the
|
||||
gateway decrypts it there to recover the user (to bind the envelope) and the upstream code (to
|
||||
exchange with the upstream), so a litellm identity captured in the browser at authorize survives
|
||||
to the back-channel token call with nothing stored server-side. Encrypted with the repo's
|
||||
authenticated symmetric helper (the same family the OAuth state uses), so the client can neither
|
||||
read nor forge it."""
|
||||
payload = json.dumps(
|
||||
{"upstream_code": upstream_code, "litellm_user_id": litellm_user_id, "mcp_server_id": mcp_server_id},
|
||||
sort_keys=True,
|
||||
)
|
||||
return _BRIDGE_AUTH_CODE_PREFIX + encrypt_value_helper(payload)
|
||||
|
||||
|
||||
def open_bridge_authorization_code(code: str) -> _BridgeAuthorizationCode | None:
|
||||
"""Recover the sealed identity and upstream code, or ``None`` when ``code`` is not a gateway
|
||||
bridge code or does not decrypt / validate. Total over hostile input: a raw upstream code (the
|
||||
scripted two-header path) returns ``None`` and the caller falls through to the existing
|
||||
behavior."""
|
||||
if not is_bridge_authorization_code(code):
|
||||
return None
|
||||
decrypted = decrypt_value_helper(
|
||||
code[len(_BRIDGE_AUTH_CODE_PREFIX) :], "bridge_authorization_code", return_original_value=False
|
||||
)
|
||||
if not isinstance(decrypted, str):
|
||||
return None
|
||||
try:
|
||||
return _BridgeAuthorizationCode.model_validate_json(decrypted)
|
||||
except ValidationError:
|
||||
return None
|
||||
|
||||
|
||||
def _redirect_to_litellm_login(request: Request) -> RedirectResponse:
|
||||
"""Send an unauthenticated browser through litellm login before the interactive bridge authorize
|
||||
can capture its identity. The bridge oauth_delegate flow seals the SSO user into the gateway code,
|
||||
so a session is required; without one there is nothing to bind. After login the user re-initiates
|
||||
the connection, which then finds the session cookie (the seamless return-to round-trip, which is
|
||||
origin-validated against the control-plane URL, is a follow-up)."""
|
||||
base_url = get_request_base_url(request)
|
||||
return RedirectResponse(f"{base_url}/sso/key/generate")
|
||||
|
||||
|
||||
# LIT-4197: some upstream authorization servers reject an over-long ``state``
|
||||
# (the encrypted OAuth session blob routinely exceeds their limit). The upstream
|
||||
# only needs an opaque value it echoes back on ``/callback``, so we forward a
|
||||
|
|
@ -414,9 +496,23 @@ async def _resolve_active_litellm_key(request: Request) -> "_ResolvedKey | _KeyR
|
|||
token = _litellm_key_from_request(request)
|
||||
if not token:
|
||||
return "no_active_key"
|
||||
from litellm.proxy._types import ( # noqa: PLC0415 # inline import avoids a module-load circular import
|
||||
ProxyException,
|
||||
hash_token,
|
||||
from litellm.proxy._types import hash_token # noqa: PLC0415 # inline import avoids a module-load circular import
|
||||
|
||||
return await _reload_active_key_by_hash(hash_token(token))
|
||||
|
||||
|
||||
async def _reload_active_key_by_hash(key_hash: str) -> "_ResolvedKey | _KeyResolutionFailure":
|
||||
"""Reload the live key record for ``key_hash`` (cache first, then DB) and gate it on active state,
|
||||
returning the resolved key or a precise failure. Shared by the token request's presented-key
|
||||
resolution (:func:`_resolve_active_litellm_key`, which hashes the presented key) and the refresh
|
||||
path (which already holds the hash sealed in the refresh envelope), so both re-validate identity
|
||||
through one active-key gate and one failure classification. Classification mirrors admission's
|
||||
``_reload_admitted_key``: no DB connection is a gateway fault, a ``ProxyException`` / ``HTTPException``
|
||||
from ``get_key_object`` is an unknown or invalid key, a database-service-unavailable error is a
|
||||
retryable outage, and anything else is an unexpected gateway fault. A blocked or expired key is
|
||||
``no_active_key``, so a revoked key can neither mint nor refresh a bridge envelope."""
|
||||
from litellm.proxy._types import (
|
||||
ProxyException, # noqa: PLC0415 # inline import avoids a module-load circular import
|
||||
)
|
||||
from litellm.proxy.auth.auth_checks import ( # noqa: PLC0415 # inline import avoids a module-load circular import
|
||||
get_key_object,
|
||||
|
|
@ -431,7 +527,6 @@ async def _resolve_active_litellm_key(request: Request) -> "_ResolvedKey | _KeyR
|
|||
|
||||
if prisma_client is None:
|
||||
return "unresolvable"
|
||||
key_hash = hash_token(token)
|
||||
try:
|
||||
key_obj = await get_key_object(
|
||||
hashed_token=key_hash,
|
||||
|
|
@ -444,7 +539,7 @@ async def _resolve_active_litellm_key(request: Request) -> "_ResolvedKey | _KeyR
|
|||
if PrismaDBExceptionHandler.is_database_service_unavailable_error(exc):
|
||||
return "unavailable"
|
||||
verbose_logger.debug(
|
||||
"_resolve_active_litellm_key: unexpected key-resolution error (%s)",
|
||||
"_reload_active_key_by_hash: unexpected key-resolution error (%s)",
|
||||
type(exc).__name__,
|
||||
)
|
||||
return "unresolvable"
|
||||
|
|
@ -453,6 +548,107 @@ async def _resolve_active_litellm_key(request: Request) -> "_ResolvedKey | _KeyR
|
|||
return _ResolvedKey(key_hash=key_hash, key=key_obj)
|
||||
|
||||
|
||||
async def _reload_active_user_by_id(user_id: str) -> "_KeyResolutionFailure | None":
|
||||
"""Re-validate a live litellm user by id, returning ``None`` when the user is active or a precise
|
||||
failure otherwise. The interactive DCR client authenticates via SSO, so its refresh envelope seals a
|
||||
user subject; renewing it must re-check the user is still live (present and not SCIM-deactivated) so a
|
||||
deactivated user cannot keep refreshing, mirroring how admission re-validates the same user subject on
|
||||
the egress side. No DB connection is a gateway fault (``unresolvable``) and a
|
||||
database-service-unavailable error is a retryable outage (``unavailable``). Everything else fails
|
||||
closed as ``no_active_key`` (the caller maps it to invalid_grant): a ``ProxyException`` /
|
||||
``HTTPException``, a SCIM-deactivated user, and, unlike the key path, a missing user. ``get_user_object``
|
||||
catches every DB failure and re-raises a bare ``ValueError`` (a deleted user and a real outage look
|
||||
identical, the original error surviving only as ``__context__``), so the outage check walks the cause
|
||||
chain, and a missing user falls through to ``no_active_key`` rather than an opaque gateway fault."""
|
||||
from litellm.proxy._types import (
|
||||
ProxyException, # noqa: PLC0415 # inline import avoids a module-load circular import
|
||||
)
|
||||
from litellm.proxy.auth.auth_checks import ( # noqa: PLC0415 # inline import avoids a module-load circular import
|
||||
get_user_object,
|
||||
)
|
||||
from litellm.proxy.db.exception_handler import ( # noqa: PLC0415 # inline import avoids a module-load circular import
|
||||
PrismaDBExceptionHandler,
|
||||
)
|
||||
from litellm.proxy.proxy_server import ( # noqa: PLC0415 # inline import avoids a module-load circular import
|
||||
prisma_client,
|
||||
user_api_key_cache,
|
||||
)
|
||||
|
||||
if prisma_client is None:
|
||||
return "unresolvable"
|
||||
try:
|
||||
user_object = await get_user_object(
|
||||
user_id=user_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
user_id_upsert=False,
|
||||
)
|
||||
except (ProxyException, HTTPException):
|
||||
return "no_active_key"
|
||||
except Exception as exc: # noqa: BLE001 # a DB outage is retryable; a missing user (get_user_object's wrapped ValueError) or any other resolution failure fails closed as no_active_key, never a 500
|
||||
if PrismaDBExceptionHandler.is_database_service_unavailable_error_in_chain(exc):
|
||||
return "unavailable"
|
||||
verbose_logger.debug("_reload_active_user_by_id: user-resolution error (%s)", type(exc).__name__)
|
||||
return "no_active_key"
|
||||
if user_object is None:
|
||||
return "no_active_key"
|
||||
if isinstance(user_object.metadata, dict) and user_object.metadata.get("scim_active") is False:
|
||||
return "no_active_key"
|
||||
return None
|
||||
|
||||
|
||||
async def _key_owner_scim_deactivated(key: "UserAPIKeyAuth") -> bool:
|
||||
"""True only when the key's owning user was explicitly SCIM-deactivated, so a refresh revokes an
|
||||
offboarded owner's key exactly as admission does via ``_reject_if_admitted_owner_scim_deactivated``.
|
||||
A key with no owner, a missing owner record, or a failed lookup fails OPEN (returns ``False``),
|
||||
matching admission and the standard builder: a key may outlive its owner record, and a transient DB
|
||||
blip must not revoke a live key. Only an explicit ``scim_active`` of ``False`` gates renewal."""
|
||||
if key.user_id is None:
|
||||
return False
|
||||
from litellm.proxy.auth.auth_checks import ( # noqa: PLC0415 # inline import avoids a module-load circular import
|
||||
get_user_object,
|
||||
)
|
||||
from litellm.proxy.proxy_server import ( # noqa: PLC0415 # inline import avoids a module-load circular import
|
||||
prisma_client,
|
||||
user_api_key_cache,
|
||||
)
|
||||
|
||||
if prisma_client is None:
|
||||
return False
|
||||
try:
|
||||
owner = await get_user_object(
|
||||
user_id=key.user_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
user_id_upsert=False,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 # fail open: a missing owner (get_user_object's wrapped ValueError) or a DB blip must not revoke a live key
|
||||
verbose_logger.debug("refresh: key-owner SCIM lookup failed, not revoking (%s)", type(exc).__name__)
|
||||
return False
|
||||
return owner is not None and isinstance(owner.metadata, dict) and owner.metadata.get("scim_active") is False
|
||||
|
||||
|
||||
async def _revalidate_active_subject(identity: "EnvelopeIdentity") -> "_KeyResolutionFailure | None":
|
||||
"""Re-validate that the subject sealed in a refresh envelope is still live, dispatching on its type:
|
||||
a key_hash reloads the virtual key, a user_id reloads the user. Returns ``None`` when the subject is
|
||||
active or a precise failure otherwise, so revocation gates renewal for either identity source the same
|
||||
way admission gates the egress: a blocked or expired key, a SCIM-deactivated key owner (mirroring
|
||||
admission's owner check, so an offboarded user cannot keep renewing a still-active key), and a
|
||||
deactivated or deleted user all fail closed to ``no_active_key``."""
|
||||
match identity.subject_type:
|
||||
case "key_hash":
|
||||
reloaded = await _reload_active_key_by_hash(identity.subject)
|
||||
if not isinstance(reloaded, _ResolvedKey):
|
||||
return reloaded
|
||||
if await _key_owner_scim_deactivated(reloaded.key):
|
||||
return "no_active_key"
|
||||
return None
|
||||
case "user_id":
|
||||
return await _reload_active_user_by_id(identity.subject)
|
||||
case _:
|
||||
assert_never(identity.subject_type)
|
||||
|
||||
|
||||
async def _extract_user_id_from_request(request: Request) -> str | None:
|
||||
"""The litellm ``user_id`` for the token request, so a per-user token is stored under the same
|
||||
identity the egress later reads it by. Storage is best-effort, so every non-resolved outcome
|
||||
|
|
@ -697,12 +893,31 @@ async def authorize_with_server(
|
|||
parsed = urlparse(redirect_uri)
|
||||
base_url = urlunparse(parsed._replace(query=""))
|
||||
request_base_url = get_request_base_url(request)
|
||||
|
||||
# Interactive dcr_bridge oauth_delegate sign-in: this arm runs the gateway /callback and /token in
|
||||
# the loop, so the gateway can capture the litellm user here (from the browser's UI session) and
|
||||
# carry it to the back-channel token mint. Seal the SSO user and the target server into the state;
|
||||
# the callback reads them back to mint the gateway authorization code. A DCR client cannot present a
|
||||
# litellm key, so the browser session is the only identity source; without one there is nothing to
|
||||
# bind, so send the user through login first. Every other oauth2 server keeps the identity-less state.
|
||||
litellm_user_id: str | None = None
|
||||
if mcp_server.is_dcr_bridge and mcp_server.is_oauth_delegate:
|
||||
from litellm.proxy._experimental.mcp_server.byok_oauth_endpoints import ( # noqa: PLC0415 # inline import avoids a module-load circular import
|
||||
_user_id_from_session_cookie,
|
||||
)
|
||||
|
||||
litellm_user_id = _user_id_from_session_cookie(request)
|
||||
if litellm_user_id is None:
|
||||
return _redirect_to_litellm_login(request)
|
||||
|
||||
encoded_state = encode_state_with_base_url(
|
||||
base_url=base_url,
|
||||
original_state=state,
|
||||
code_challenge=code_challenge,
|
||||
code_challenge_method=code_challenge_method,
|
||||
client_redirect_uri=redirect_uri,
|
||||
litellm_user_id=litellm_user_id,
|
||||
mcp_server_id=mcp_server.server_id if litellm_user_id else None,
|
||||
)
|
||||
relay_state = secrets.token_urlsafe(_OAUTH_STATE_HANDLE_BYTES)
|
||||
|
||||
|
|
@ -812,7 +1027,7 @@ def _bridge_grant_from_token_response(token_response: object) -> "UpstreamTokenG
|
|||
|
||||
_BridgeMintError = Literal[
|
||||
"no_identity",
|
||||
"unsupported_grant",
|
||||
"invalid_refresh",
|
||||
"identity_unavailable",
|
||||
"identity_unresolvable",
|
||||
"not_configured",
|
||||
|
|
@ -824,11 +1039,14 @@ _BridgeMintError = Literal[
|
|||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _BridgeMintReady:
|
||||
"""Everything the seal needs, resolved once before the exchange: the authorizing key hash and the
|
||||
master-key-derived envelope keys. Passing this forward means identity resolution and key derivation
|
||||
happen exactly once, and ``_finish_bridge_mint`` has no preconditions left that could fail."""
|
||||
"""Everything the seal needs, resolved once before the exchange: the identity to bind the envelope
|
||||
to and the master-key-derived envelope keys. The identity is a key_hash subject for the scripted
|
||||
two-header client (resolved from the litellm key it presents) or a user_id subject for the
|
||||
interactive SSO client (the user recovered from the gateway authorization code), so one phase-3 seal
|
||||
serves both. Resolving identity here means ``_finish_bridge_mint`` has no preconditions left to
|
||||
fail."""
|
||||
|
||||
key_hash: str
|
||||
identity: "EnvelopeIdentity"
|
||||
keys: "EnvelopeKeys"
|
||||
|
||||
|
||||
|
|
@ -844,15 +1062,15 @@ def _bridge_mint_error_response(error: _BridgeMintError) -> JSONResponse:
|
|||
status, code, desc = (
|
||||
400,
|
||||
"invalid_request",
|
||||
"this server issues a gateway-bound credential; send a litellm credential "
|
||||
"(x-litellm-api-key or Authorization) on the token request",
|
||||
"this server issues a gateway-bound credential; complete the interactive sign-in, or "
|
||||
"send a litellm credential (x-litellm-api-key or Authorization) on the token request",
|
||||
)
|
||||
case "unsupported_grant":
|
||||
case "invalid_refresh":
|
||||
status, code, desc = (
|
||||
400,
|
||||
"unsupported_grant_type",
|
||||
"this server issues a gateway-bound credential and supports only the authorization_code "
|
||||
"grant; re-run authorization_code to renew rather than refresh_token",
|
||||
"invalid_grant",
|
||||
"the refresh credential is not a valid, live refresh envelope for this server; "
|
||||
"re-run authorization_code to obtain a new one",
|
||||
)
|
||||
case "identity_unavailable":
|
||||
status, code, desc = (
|
||||
|
|
@ -923,45 +1141,130 @@ def _upstream_rejection_to_mint_error(rejection: _UpstreamGrantRejection) -> _Br
|
|||
assert_never(rejection)
|
||||
|
||||
|
||||
async def _prepare_bridge_mint(request: Request, grant_type: str) -> "_BridgeMintReady | _BridgeMintError":
|
||||
"""Phase 1, BEFORE the upstream exchange: reject a grant this mint does not support, confirm the
|
||||
gateway can mint (master_key set), resolve the litellm identity, and derive the envelope keys.
|
||||
Returns a ready context or a precise failure value. Running before the exchange is what makes every
|
||||
failure here fail closed without consuming the single-use code or rotating a refresh token. A bridge
|
||||
server issues only envelopes and seals no upstream refresh_token, so the client holds none to
|
||||
present: the refresh_token grant is rejected up front rather than exchanged (which could rotate the
|
||||
upstream credential) and its result then discarded. Identity-resolution failures keep their origin
|
||||
so the mapper statuses each truthfully."""
|
||||
async def _prepare_bridge_mint(
|
||||
request: Request,
|
||||
mcp_server: MCPServer,
|
||||
bridge_identity: _BridgeAuthorizationCode | None = None,
|
||||
) -> "_BridgeMintReady | _BridgeMintError":
|
||||
"""Phase 1 for the authorization_code grant, BEFORE the upstream exchange: confirm the gateway can
|
||||
mint (master_key set), resolve the litellm identity, and derive the envelope keys. Returns a ready
|
||||
context or a precise failure value. Running before the exchange is what makes every failure here fail
|
||||
closed without consuming the single-use code.
|
||||
|
||||
Two identity sources, one envelope. The interactive DCR client authenticates via SSO at the bridged
|
||||
authorize, so its identity arrives as ``bridge_identity`` (the user recovered from the gateway
|
||||
authorization code) and mints a user subject. The scripted two-header client presents a litellm key
|
||||
on the token request instead, so its identity is the active key's hash and mints a key_hash subject.
|
||||
A missing or invalid presented key keeps its resolution origin so the mapper statuses it truthfully;
|
||||
neither source present is ``no_identity``. The refresh_token grant has its own phase-1
|
||||
(:func:`_prepare_bridge_refresh`), which recovers identity from the presented refresh envelope."""
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import ( # noqa: PLC0415 # inline import avoids a module-load circular import
|
||||
envelope_keys_from_master_key,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ( # noqa: PLC0415 # inline import avoids a module-load circular import
|
||||
key_hash_identity,
|
||||
user_identity,
|
||||
)
|
||||
from litellm.proxy.proxy_server import ( # noqa: PLC0415 # inline import avoids a module-load circular import
|
||||
master_key,
|
||||
)
|
||||
|
||||
if grant_type != "authorization_code":
|
||||
return "unsupported_grant"
|
||||
if not master_key:
|
||||
return "not_configured"
|
||||
keys = envelope_keys_from_master_key(master_key)
|
||||
if bridge_identity is not None:
|
||||
identity = user_identity(server_id=mcp_server.server_id, user_id=bridge_identity.litellm_user_id)
|
||||
return _BridgeMintReady(identity=identity, keys=keys)
|
||||
resolved = await _resolve_active_litellm_key(request)
|
||||
if not isinstance(resolved, _ResolvedKey):
|
||||
return _key_resolution_failure_to_mint_error(resolved)
|
||||
return _BridgeMintReady(key_hash=resolved.key_hash, keys=envelope_keys_from_master_key(master_key))
|
||||
identity = key_hash_identity(server_id=mcp_server.server_id, key_hash=resolved.key_hash)
|
||||
return _BridgeMintReady(identity=identity, keys=keys)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _BridgeRefreshReady:
|
||||
"""A validated refresh request: the identity+keys to mint the renewed pair under, the upstream refresh
|
||||
token (unwrapped from the client's refresh envelope) to exchange with the upstream IdP, and the scope
|
||||
sealed alongside it at mint. The upstream refresh token is a ``SecretStr`` like every other credential
|
||||
in this layer, so a repr or a traceback that captures this value never exposes the raw upstream refresh
|
||||
token in plaintext. ``upstream_scope`` carries the originally-granted scope so the renewal re-requests
|
||||
it when the client (a DCR/MCP client that typically omits scope on refresh) sends none, keeping the
|
||||
renewed token's scope stable against an upstream that would otherwise narrow or drop it."""
|
||||
|
||||
ready: "_BridgeMintReady"
|
||||
upstream_refresh_token: SecretStr
|
||||
upstream_scope: str | None = None
|
||||
|
||||
|
||||
def _refresh_key_failure_to_mint_error(failure: _KeyResolutionFailure) -> _BridgeMintError:
|
||||
"""Lift an identity-resolution failure on the refresh path into the mint taxonomy. Unlike the mint
|
||||
path, a resolved-but-inactive (or unknown) key is ``invalid_grant`` rather than ``invalid_request``:
|
||||
the client did present an identity (sealed in the refresh envelope), but it is no longer live, so the
|
||||
refresh is invalid and the client must re-authenticate. A transient outage is still 503 and a gateway
|
||||
fault still 500, matching the mint path and admission."""
|
||||
match failure:
|
||||
case "no_active_key":
|
||||
return "invalid_refresh"
|
||||
case "unavailable":
|
||||
return "identity_unavailable"
|
||||
case "unresolvable":
|
||||
return "identity_unresolvable"
|
||||
case _:
|
||||
assert_never(failure)
|
||||
|
||||
|
||||
async def _prepare_bridge_refresh(
|
||||
mcp_server: MCPServer, refresh_value: str | None
|
||||
) -> "_BridgeRefreshReady | _BridgeMintError":
|
||||
"""Phase 1 for the refresh_token grant, BEFORE the upstream exchange: open the client's refresh
|
||||
envelope, re-validate the sealed litellm identity so a revoked key cannot keep refreshing, and
|
||||
recover the upstream refresh token to exchange. Identity comes entirely from the sealed envelope, not
|
||||
the HTTP request, so the request object is not needed here. The client presents a refresh envelope,
|
||||
never a raw upstream refresh token, so a missing value, a non-envelope, an unopenable envelope, or one
|
||||
minted for another server is ``invalid_grant``. Running before the exchange means a rejected refresh
|
||||
never consumes or rotates the upstream refresh token."""
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import ( # noqa: PLC0415 # inline import avoids a module-load circular import
|
||||
BridgeRefreshOpened,
|
||||
envelope_keys_from_master_key,
|
||||
open_bridge_refresh_envelope,
|
||||
)
|
||||
from litellm.proxy.proxy_server import ( # noqa: PLC0415 # inline import avoids a module-load circular import
|
||||
master_key,
|
||||
)
|
||||
|
||||
if not master_key:
|
||||
return "not_configured"
|
||||
if not refresh_value:
|
||||
return "invalid_refresh"
|
||||
keys = envelope_keys_from_master_key(master_key)
|
||||
opened = open_bridge_refresh_envelope(refresh_value, keys, datetime.now(timezone.utc), mcp_server.server_id)
|
||||
if not isinstance(opened, BridgeRefreshOpened):
|
||||
return "invalid_refresh"
|
||||
failure = await _revalidate_active_subject(opened.identity)
|
||||
if failure is not None:
|
||||
return _refresh_key_failure_to_mint_error(failure)
|
||||
return _BridgeRefreshReady(
|
||||
ready=_BridgeMintReady(identity=opened.identity, keys=keys),
|
||||
upstream_refresh_token=opened.refresh.refresh_token,
|
||||
upstream_scope=opened.refresh.scope,
|
||||
)
|
||||
|
||||
|
||||
def _finish_bridge_mint(
|
||||
ready: "_BridgeMintReady", mcp_server: MCPServer, token_response: object, now: datetime
|
||||
) -> "JSONResponse | _BridgeMintError":
|
||||
"""Phase 3, AFTER the upstream exchange: seal the upstream grant into the client-held envelope using
|
||||
the pre-resolved identity and keys, so the client holds one bearer that admits it and forwards the
|
||||
upstream token with nothing stored server-side. The only failures here are properties of the
|
||||
upstream response (no usable token, an already-expired lifetime, or a token too large to seal),
|
||||
returned as values."""
|
||||
"""Phase 3, AFTER the upstream exchange: seal the upstream grant into the client-held access envelope
|
||||
using the pre-resolved identity and keys, and, when the upstream returned a refresh token, seal a
|
||||
long-lived refresh envelope alongside it so the client can renew without re-authenticating. Shared by
|
||||
the authorization_code and refresh_token paths, so a renewal that the upstream rotates re-issues a
|
||||
fresh refresh envelope. The only hard failures here are properties of the upstream access token (no
|
||||
usable token, an already-expired lifetime, or a token too large to seal); a refresh token that cannot
|
||||
be sealed degrades to an access-only response rather than failing the whole exchange."""
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import ( # noqa: PLC0415 # inline import avoids a module-load circular import
|
||||
build_bridge_token_response,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ( # noqa: PLC0415 # inline import avoids a module-load circular import
|
||||
EnvelopeIdentity,
|
||||
SealedEnvelope,
|
||||
UpstreamTokenGrant,
|
||||
)
|
||||
|
|
@ -969,17 +1272,88 @@ def _finish_bridge_mint(
|
|||
grant = _bridge_grant_from_token_response(token_response)
|
||||
if not isinstance(grant, UpstreamTokenGrant):
|
||||
return _upstream_rejection_to_mint_error(grant)
|
||||
identity = EnvelopeIdentity(server_id=mcp_server.server_id, key_hash=ready.key_hash)
|
||||
sealed = build_bridge_token_response(identity, grant, ready.keys, now)
|
||||
sealed = build_bridge_token_response(ready.identity, grant, ready.keys, now)
|
||||
if not isinstance(sealed, SealedEnvelope):
|
||||
return "too_large"
|
||||
# Report expires_in from the JWT's own second-truncated exp, rounding the elapsed portion up, so the
|
||||
# client is never told the bearer lives past the point admission (which uses that exp) rejects it.
|
||||
expires_in = max(0, int(sealed.expires_at.timestamp()) - math.ceil(now.timestamp()))
|
||||
body = {"access_token": sealed.token.get_secret_value(), "token_type": "Bearer", "expires_in": expires_in}
|
||||
refresh_envelope = _mint_refresh_envelope_value(ready.identity, token_response, ready.keys, now, mcp_server)
|
||||
body = {
|
||||
"access_token": sealed.token.get_secret_value(),
|
||||
"token_type": "Bearer",
|
||||
"expires_in": expires_in,
|
||||
# A refresh envelope rides along only when the upstream returned a refresh token to seal; when it
|
||||
# rotates on renewal, the client receives the new one and the old envelope's upstream token dies.
|
||||
**({"refresh_token": refresh_envelope} if refresh_envelope is not None else {}),
|
||||
}
|
||||
return JSONResponse(body, headers=TOKEN_NO_CACHE_HEADERS)
|
||||
|
||||
|
||||
def _token_credential_source(mcp_server: MCPServer) -> CredentialSource:
|
||||
"""Mirrors the resolved-client rule in :func:`exchange_token_with_server`: when the server has a
|
||||
stored client_id the gateway presents its own credentials upstream, so a credential rejection is
|
||||
the operator's fault, not the caller's."""
|
||||
return "gateway_stored" if mcp_server.client_id else "caller_supplied"
|
||||
|
||||
|
||||
def _upstream_refresh_credential(token_response: object) -> "RefreshCredential | None":
|
||||
"""Extract the upstream refresh grant from a token response, or ``None`` when there is none to seal.
|
||||
Each field is isinstance-checked so nothing untyped reaches the refresh envelope; ``refresh_expires_in``
|
||||
(the refresh token's own lifetime, when the upstream reports it) is classified like ``expires_in`` and
|
||||
bounds the refresh envelope's TTL. An upstream that reports the refresh token itself as already elapsed
|
||||
(``refresh_expires_in`` non-positive) yields ``None`` rather than a refresh envelope: sealing a dead
|
||||
token would hand the client a full-TTL-capped envelope the IdP will reject, so the exchange degrades to
|
||||
an access-only response (the client re-authenticates at access expiry), mirroring how
|
||||
:func:`_bridge_grant_from_token_response` refuses an already-elapsed access token instead of capping it."""
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ( # noqa: PLC0415 # inline import avoids a module-load circular import
|
||||
RefreshCredential,
|
||||
)
|
||||
|
||||
if not isinstance(token_response, dict):
|
||||
return None
|
||||
refresh = token_response.get("refresh_token")
|
||||
if not isinstance(refresh, str) or not refresh:
|
||||
return None
|
||||
lifetime = _classify_upstream_lifetime(token_response.get("refresh_expires_in"))
|
||||
if lifetime == "expired":
|
||||
return None
|
||||
scope = token_response.get("scope")
|
||||
return RefreshCredential(
|
||||
refresh_token=SecretStr(refresh),
|
||||
scope=scope if isinstance(scope, str) and scope else None,
|
||||
expires_in=lifetime if isinstance(lifetime, int) else None,
|
||||
)
|
||||
|
||||
|
||||
def _mint_refresh_envelope_value(
|
||||
identity: "EnvelopeIdentity", token_response: object, keys: "EnvelopeKeys", now: datetime, mcp_server: MCPServer
|
||||
) -> str | None:
|
||||
"""Seal the upstream refresh grant (if any) into a refresh envelope and return its bearer string, or
|
||||
``None`` when the upstream returned no refresh token or the refresh token is too large to seal. A
|
||||
too-large refresh token degrades to an access-only response (logged) rather than failing an exchange
|
||||
that already succeeded upstream: the client simply re-authenticates when the access envelope expires."""
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import ( # noqa: PLC0415 # inline import avoids a module-load circular import
|
||||
build_bridge_refresh_token_response,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ( # noqa: PLC0415 # inline import avoids a module-load circular import
|
||||
SealedEnvelope,
|
||||
)
|
||||
|
||||
refresh_credential = _upstream_refresh_credential(token_response)
|
||||
if refresh_credential is None:
|
||||
return None
|
||||
sealed = build_bridge_refresh_token_response(identity, refresh_credential, keys, now)
|
||||
if isinstance(sealed, SealedEnvelope):
|
||||
return sealed.token.get_secret_value()
|
||||
verbose_logger.warning(
|
||||
"bridge mint: the upstream refresh token is too large to seal into a refresh envelope for "
|
||||
"server=%s; issuing an access-only response, so the client re-authenticates at access expiry",
|
||||
mcp_server.server_id,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def exchange_token_with_server(
|
||||
request: Request,
|
||||
mcp_server: MCPServer,
|
||||
|
|
@ -1014,25 +1388,61 @@ async def exchange_token_with_server(
|
|||
except TokenEndpointAuthConfigError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
bridge_identity: _BridgeAuthorizationCode | None = None
|
||||
bridge_mint_ready: _BridgeMintReady | None = None
|
||||
bridge_upstream_refresh: SecretStr | None = None
|
||||
bridge_upstream_scope: str | None = None
|
||||
refresh_request_scope: str | None = None
|
||||
is_bridge = mcp_server.is_oauth_delegate and mcp_server.is_dcr_bridge
|
||||
|
||||
if grant_type == "refresh_token":
|
||||
if not refresh_token:
|
||||
# Phase 1 for a bridge refresh: open the client's refresh envelope, re-validate the sealed
|
||||
# identity, and unwrap the real upstream refresh token BEFORE building token_data, so the exchange
|
||||
# sends the upstream token and never the envelope. A failure returns without touching the upstream.
|
||||
if is_bridge:
|
||||
prepared_refresh = await _prepare_bridge_refresh(mcp_server, refresh_token)
|
||||
if not isinstance(prepared_refresh, _BridgeRefreshReady):
|
||||
return _bridge_mint_error_response(prepared_refresh)
|
||||
bridge_mint_ready = prepared_refresh.ready
|
||||
bridge_upstream_refresh = prepared_refresh.upstream_refresh_token
|
||||
bridge_upstream_scope = prepared_refresh.upstream_scope
|
||||
# A bridge server sends the unwrapped upstream refresh token recovered from the client's refresh
|
||||
# envelope above; every other server sends the client's own refresh token verbatim.
|
||||
upstream_refresh_token = (
|
||||
bridge_upstream_refresh.get_secret_value() if bridge_upstream_refresh is not None else refresh_token
|
||||
)
|
||||
if not upstream_refresh_token:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="refresh_token is required for refresh_token grant",
|
||||
)
|
||||
token_data: dict = {
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token,
|
||||
"refresh_token": upstream_refresh_token,
|
||||
**client_auth.body,
|
||||
}
|
||||
if scope:
|
||||
token_data["scope"] = scope
|
||||
refresh_request_scope = scope or bridge_upstream_scope
|
||||
if refresh_request_scope:
|
||||
token_data["scope"] = refresh_request_scope
|
||||
else:
|
||||
if not code:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="code is required for authorization_code grant",
|
||||
)
|
||||
# Interactive dcr_bridge oauth_delegate: the client presents the gateway authorization code the
|
||||
# callback sealed. Recover the SSO user and the real upstream code from it; the upstream exchange
|
||||
# below uses the upstream code, and the mint binds the envelope to the recovered user. Bind the
|
||||
# sealed server to this request so a code minted for one bridge server cannot be spent at another.
|
||||
# A raw upstream code (scripted path) opens to None and the code is used as-is.
|
||||
bridge_identity = open_bridge_authorization_code(code)
|
||||
if bridge_identity is not None:
|
||||
if bridge_identity.mcp_server_id != mcp_server.server_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Authorization code was issued for a different MCP server",
|
||||
)
|
||||
code = bridge_identity.upstream_code
|
||||
bridge_token_relay = _dcr_bridge_relays_client_registration(mcp_server)
|
||||
if bridge_token_relay and not redirect_uri:
|
||||
raise HTTPException(
|
||||
|
|
@ -1052,40 +1462,48 @@ async def exchange_token_with_server(
|
|||
}
|
||||
if code_verifier:
|
||||
token_data["code_verifier"] = code_verifier
|
||||
|
||||
# Phase 1: for a bridge oauth_delegate mint, validate all preconditions and resolve identity+keys
|
||||
# BEFORE the exchange below consumes the single-use upstream code, and carry the ready context to
|
||||
# phase 3. A failure here returns without ever touching the upstream credential.
|
||||
bridge_mint_ready: _BridgeMintReady | None = None
|
||||
if mcp_server.is_oauth_delegate and mcp_server.is_dcr_bridge:
|
||||
prepared = await _prepare_bridge_mint(request, grant_type)
|
||||
if not isinstance(prepared, _BridgeMintReady):
|
||||
return _bridge_mint_error_response(prepared)
|
||||
bridge_mint_ready = prepared
|
||||
|
||||
# Phase 1 for a bridge authorization_code mint: resolve identity (the SSO user recovered above, or
|
||||
# the presented litellm key) and the envelope keys BEFORE the exchange consumes the single-use code.
|
||||
if is_bridge:
|
||||
prepared = await _prepare_bridge_mint(request, mcp_server, bridge_identity)
|
||||
if not isinstance(prepared, _BridgeMintReady):
|
||||
return _bridge_mint_error_response(prepared)
|
||||
bridge_mint_ready = prepared
|
||||
async_client = get_async_httpx_client(llm_provider=httpxSpecialProvider.Oauth2Check)
|
||||
response = await async_client.post(
|
||||
mcp_server.token_url,
|
||||
headers={"Accept": "application/json", **client_auth.headers},
|
||||
data=token_data,
|
||||
)
|
||||
try:
|
||||
response = await async_client.post(
|
||||
mcp_server.token_url,
|
||||
headers={"Accept": "application/json", **client_auth.headers},
|
||||
data=token_data,
|
||||
)
|
||||
if response is not None:
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
fault = classify_upstream_token_rejection(
|
||||
exc.response,
|
||||
credential_source=_token_credential_source(mcp_server),
|
||||
log_context=mcp_server.server_id,
|
||||
)
|
||||
upstream_rejected_bridge_refresh = (
|
||||
is_bridge
|
||||
and grant_type == "refresh_token"
|
||||
and isinstance(fault, CallerRejected)
|
||||
and fault.code == "invalid_grant"
|
||||
)
|
||||
if upstream_rejected_bridge_refresh:
|
||||
verbose_logger.info(
|
||||
"bridge refresh: the upstream rejected the sealed refresh token for server=%s with "
|
||||
"invalid_grant (revoked or expired at the IdP); returning invalid_grant so the client "
|
||||
"re-runs authorization_code rather than an opaque upstream error",
|
||||
mcp_server.server_id,
|
||||
)
|
||||
return _bridge_mint_error_response("invalid_refresh")
|
||||
return render_token_fault(fault)
|
||||
if response is None:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="MCP upstream token endpoint returned no response",
|
||||
)
|
||||
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if "invalid_target" in exc.response.text:
|
||||
verbose_logger.warning(
|
||||
"MCP server %s: the upstream authorization server rejected the token request with "
|
||||
"invalid_target; it may require RFC 8707 resource indicators, which the gateway "
|
||||
"does not send yet (tracked as LIT-4339)",
|
||||
mcp_server.server_id,
|
||||
)
|
||||
raise
|
||||
token_response = response.json()
|
||||
|
||||
# Validate token response against server-configured rules before any storage.
|
||||
|
|
@ -1130,13 +1548,19 @@ async def exchange_token_with_server(
|
|||
# upstream token) instead of the raw upstream token, so the one bearer both admits the caller and
|
||||
# forwards the upstream credential. Only this mode mints; every other server returns the raw token.
|
||||
if bridge_mint_ready is not None:
|
||||
if refresh_request_scope and isinstance(token_response, dict) and not token_response.get("scope"):
|
||||
token_response = {**token_response, "scope": refresh_request_scope}
|
||||
# Phase 3: seal the upstream grant into the client-held envelope; failures map through the same
|
||||
# OAuth-shaped response as the phase-1 preconditions.
|
||||
minted = _finish_bridge_mint(bridge_mint_ready, mcp_server, token_response, datetime.now(timezone.utc))
|
||||
return minted if isinstance(minted, JSONResponse) else _bridge_mint_error_response(minted)
|
||||
|
||||
raw_access_token = token_response.get("access_token") if isinstance(token_response, dict) else None
|
||||
if not isinstance(raw_access_token, str) or not raw_access_token:
|
||||
return render_token_fault(UpstreamProtocolFault(note="the upstream token response has no usable access_token"))
|
||||
|
||||
result = {
|
||||
"access_token": token_response["access_token"],
|
||||
"access_token": raw_access_token,
|
||||
"token_type": token_response.get("token_type", "Bearer"),
|
||||
}
|
||||
|
||||
|
|
@ -1392,21 +1816,6 @@ async def _persist_dcr_client_registration(
|
|||
return "failed"
|
||||
|
||||
|
||||
_MAX_UPSTREAM_ERROR_CHARS = 500
|
||||
|
||||
|
||||
def _safe_upstream_error_detail(response: httpx.Response) -> str:
|
||||
"""Bounded plaintext summary of an upstream registration failure for the client.
|
||||
|
||||
RFC 7591 error bodies are small JSON objects (``error`` / ``error_description``); relaying the
|
||||
text lets the client read the real reason instead of a bare 500, and the length bound keeps a
|
||||
hostile or oversized upstream body from bloating the gateway response."""
|
||||
body = response.text
|
||||
if not body:
|
||||
return response.reason_phrase or "upstream registration failed"
|
||||
return body[:_MAX_UPSTREAM_ERROR_CHARS]
|
||||
|
||||
|
||||
async def register_client_with_server(
|
||||
request: Request,
|
||||
mcp_server: MCPServer,
|
||||
|
|
@ -1466,19 +1875,24 @@ async def register_client_with_server(
|
|||
}
|
||||
|
||||
async_client = get_async_httpx_client(llm_provider=httpxSpecialProvider.Oauth2Register)
|
||||
response = await async_client.post(
|
||||
mcp_server.registration_url,
|
||||
headers=headers,
|
||||
json=register_data,
|
||||
)
|
||||
try:
|
||||
response = await async_client.post(
|
||||
mcp_server.registration_url,
|
||||
headers=headers,
|
||||
json=register_data,
|
||||
)
|
||||
if response is not None:
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
status_code, detail = dcr_fault_detail(
|
||||
classify_upstream_dcr_rejection(exc.response, log_context=mcp_server.server_id)
|
||||
)
|
||||
raise HTTPException(status_code=status_code, detail=detail) from exc
|
||||
if response is None:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="MCP upstream registration endpoint returned no response",
|
||||
)
|
||||
if bridge_relay and response.status_code >= 400:
|
||||
raise HTTPException(status_code=response.status_code, detail=_safe_upstream_error_detail(response))
|
||||
response.raise_for_status()
|
||||
|
||||
token_response = response.json()
|
||||
|
||||
|
|
@ -1706,7 +2120,20 @@ async def callback(
|
|||
# states while permitting same-origin / allowlisted clients.
|
||||
redirect_uri = _get_validated_client_redirect_uri(request, state_data)
|
||||
|
||||
params = {"code": code, "state": original_state}
|
||||
# Interactive dcr_bridge oauth_delegate: the state carries the litellm user the authorize step
|
||||
# captured. Instead of forwarding the raw upstream code (which the client would present at the
|
||||
# token endpoint with no way to prove who signed in), seal the user and the upstream code into a
|
||||
# gateway authorization code and forward THAT. The token endpoint decrypts it to bind the
|
||||
# envelope to this user. Every other flow forwards the raw code unchanged.
|
||||
litellm_user_id = state_data.get("litellm_user_id")
|
||||
mcp_server_id = state_data.get("mcp_server_id")
|
||||
forwarded_code = code
|
||||
if isinstance(litellm_user_id, str) and litellm_user_id and isinstance(mcp_server_id, str) and mcp_server_id:
|
||||
forwarded_code = seal_bridge_authorization_code(
|
||||
upstream_code=code, litellm_user_id=litellm_user_id, mcp_server_id=mcp_server_id
|
||||
)
|
||||
|
||||
params = {"code": forwarded_code, "state": original_state}
|
||||
complete_returned_url = _append_query_params(redirect_uri, params)
|
||||
response = RedirectResponse(url=complete_returned_url, status_code=302)
|
||||
_clear_oauth_state_cookie(response, request, state)
|
||||
|
|
|
|||
38
litellm/proxy/_experimental/mcp_server/faults/__init__.py
Normal file
38
litellm/proxy/_experimental/mcp_server/faults/__init__.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
"""Typed fault values for upstream OAuth/DCR failures (phase 1 of the MCP error-handling framework).
|
||||
|
||||
The invariant this package exists to enforce: an upstream failure is classified ONCE into a single
|
||||
fault value, and the response status, wire error code, and prose are all derived from that value.
|
||||
Deriving all three from one classification makes contradictory pairings (a caller-fault error code on
|
||||
a server-fault status) unrepresentable, and gives the trust-boundary rule one enforcement point:
|
||||
spec-defined machine fields may cross to callers, upstream prose and raw bodies go to server logs.
|
||||
"""
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.faults.classify import (
|
||||
classify_upstream_dcr_rejection,
|
||||
classify_upstream_token_rejection,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.faults.render_oauth import (
|
||||
dcr_fault_detail,
|
||||
render_token_fault,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.faults.types import (
|
||||
CallerRejected,
|
||||
CredentialSource,
|
||||
GatewayRejected,
|
||||
UpstreamOAuthFault,
|
||||
UpstreamProtocolFault,
|
||||
UpstreamReportedFault,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CallerRejected",
|
||||
"CredentialSource",
|
||||
"GatewayRejected",
|
||||
"UpstreamOAuthFault",
|
||||
"UpstreamProtocolFault",
|
||||
"UpstreamReportedFault",
|
||||
"classify_upstream_dcr_rejection",
|
||||
"classify_upstream_token_rejection",
|
||||
"dcr_fault_detail",
|
||||
"render_token_fault",
|
||||
]
|
||||
133
litellm/proxy/_experimental/mcp_server/faults/classify.py
Normal file
133
litellm/proxy/_experimental/mcp_server/faults/classify.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
"""The single place that reads upstream OAuth/DCR failure responses.
|
||||
|
||||
Every accessor here is total: an upstream that lies about its content encoding, sends an undecodable
|
||||
body, or omits the spec fields yields a classified fault, never an exception. Nothing outside this
|
||||
module should touch a failed upstream response's body.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.proxy._experimental.mcp_server.faults.types import (
|
||||
GATEWAY_CAPABILITY_CODES,
|
||||
GATEWAY_CREDENTIAL_CODES,
|
||||
MAX_WIRE_FIELD_CHARS,
|
||||
CallerRejected,
|
||||
CredentialSource,
|
||||
GatewayRejected,
|
||||
UpstreamOAuthFault,
|
||||
UpstreamProtocolFault,
|
||||
UpstreamReportedFault,
|
||||
)
|
||||
|
||||
|
||||
def _safe_text(response: httpx.Response) -> str:
|
||||
try:
|
||||
return response.text
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _safe_json(response: httpx.Response) -> object:
|
||||
try:
|
||||
return response.json()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _bounded_field(value: object) -> str | None:
|
||||
if not isinstance(value, str) or not value:
|
||||
return None
|
||||
return value[:MAX_WIRE_FIELD_CHARS]
|
||||
|
||||
|
||||
def _log_out_of_contract(endpoint_kind: str, response: httpx.Response, log_context: str) -> None:
|
||||
verbose_logger.warning(
|
||||
"MCP upstream %s endpoint (%s) returned HTTP %s outside the OAuth error contract (first %s chars): %s",
|
||||
endpoint_kind,
|
||||
log_context,
|
||||
response.status_code,
|
||||
MAX_WIRE_FIELD_CHARS,
|
||||
_safe_text(response)[:MAX_WIRE_FIELD_CHARS],
|
||||
)
|
||||
|
||||
|
||||
def _classify_oauth_error_code(
|
||||
code: str,
|
||||
description: str | None,
|
||||
error_uri: str | None,
|
||||
credential_source: CredentialSource,
|
||||
log_context: str,
|
||||
) -> UpstreamOAuthFault:
|
||||
"""Blame assignment for a contract-conformant OAuth error code, shared by the token and DCR
|
||||
classifiers. Codes by which the upstream blames itself keep that blame; ``invalid_target`` is a
|
||||
gateway capability gap (RFC 8707 resource indicators, LIT-4339) no matter whose credentials were
|
||||
presented; credential-indicting codes follow the credential source; everything else, including
|
||||
codes we do not recognize, is the caller's to act on. The upstream's HTTP status is deliberately
|
||||
never consulted: status derives from this classification at render time, which is what keeps
|
||||
status and code from contradicting each other."""
|
||||
if code == "server_error" or code == "temporarily_unavailable":
|
||||
return UpstreamReportedFault(code=code)
|
||||
if code in GATEWAY_CAPABILITY_CODES:
|
||||
verbose_logger.warning(
|
||||
"MCP server %s: the upstream authorization server rejected the request with "
|
||||
"invalid_target; it may require RFC 8707 resource indicators, which the gateway "
|
||||
"does not send yet (tracked as LIT-4339)",
|
||||
log_context,
|
||||
)
|
||||
return GatewayRejected(code=code)
|
||||
if credential_source == "gateway_stored" and code in GATEWAY_CREDENTIAL_CODES:
|
||||
verbose_logger.warning(
|
||||
"MCP server %s: upstream authorization server rejected the gateway's configured client "
|
||||
"credentials (%s): %s",
|
||||
log_context,
|
||||
code,
|
||||
description or "<no description>",
|
||||
)
|
||||
return GatewayRejected(code=code)
|
||||
return CallerRejected(code=code, description=description, error_uri=error_uri)
|
||||
|
||||
|
||||
def classify_upstream_token_rejection(
|
||||
response: httpx.Response,
|
||||
credential_source: CredentialSource,
|
||||
log_context: str,
|
||||
) -> UpstreamOAuthFault:
|
||||
"""Classify a token-endpoint rejection into exactly one fault: a body with an RFC 6749 §5.2
|
||||
``error`` field goes through blame assignment (:func:`_classify_oauth_error_code`); anything
|
||||
without a usable ``error`` field is an upstream protocol fault."""
|
||||
parsed = _safe_json(response)
|
||||
fields = parsed if isinstance(parsed, dict) else {}
|
||||
code = _bounded_field(fields.get("error"))
|
||||
if code is None:
|
||||
_log_out_of_contract("token", response, log_context)
|
||||
return UpstreamProtocolFault(note=f"upstream token endpoint returned HTTP {response.status_code}")
|
||||
return _classify_oauth_error_code(
|
||||
code,
|
||||
description=_bounded_field(fields.get("error_description")),
|
||||
error_uri=_bounded_field(fields.get("error_uri")),
|
||||
credential_source=credential_source,
|
||||
log_context=log_context,
|
||||
)
|
||||
|
||||
|
||||
def classify_upstream_dcr_rejection(response: httpx.Response, log_context: str) -> UpstreamOAuthFault:
|
||||
"""Classify a dynamic-client-registration rejection. RFC 7591 §3.2.2 errors carry
|
||||
``error`` / ``error_description`` and go through the same blame assignment as token errors
|
||||
(registration sends no client credentials, so credential codes stay caller-actionable); anything
|
||||
without a usable ``error`` field is an upstream protocol fault."""
|
||||
parsed = _safe_json(response)
|
||||
fields = parsed if isinstance(parsed, dict) else {}
|
||||
code = _bounded_field(fields.get("error"))
|
||||
if code is None:
|
||||
_log_out_of_contract("registration", response, log_context)
|
||||
return UpstreamProtocolFault(note=f"upstream registration failed with HTTP {response.status_code}")
|
||||
return _classify_oauth_error_code(
|
||||
code,
|
||||
description=_bounded_field(fields.get("error_description")),
|
||||
error_uri=None,
|
||||
credential_source="caller_supplied",
|
||||
log_context=log_context,
|
||||
)
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
"""Render upstream OAuth/DCR faults onto the wire. The only place that chooses statuses and bodies
|
||||
for these faults, so every consumer emits the same contract: RFC 6749 §5.2-shaped JSON with the §5.1
|
||||
no-store headers on token endpoints, HTTPException details on registration. Status, code, and prose
|
||||
all derive from the fault tag; exhaustive matches keep a new fault arm from shipping unrendered.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.responses import JSONResponse
|
||||
from typing_extensions import assert_never
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.faults.types import UpstreamOAuthFault
|
||||
from litellm.proxy._experimental.mcp_server.oauth_utils import TOKEN_NO_CACHE_HEADERS
|
||||
|
||||
|
||||
def _gateway_rejected_description(code: str) -> str:
|
||||
if code == "invalid_target":
|
||||
return (
|
||||
"the upstream authorization server rejected the request (invalid_target); "
|
||||
"it may require RFC 8707 resource indicators, which the gateway does not send yet"
|
||||
)
|
||||
return (
|
||||
f"the upstream authorization server rejected the gateway's configured client credentials "
|
||||
f"({code}); verify the MCP server's client_id and client_secret"
|
||||
)
|
||||
|
||||
|
||||
def _upstream_reported_status_and_description(code: str) -> tuple[int, str]:
|
||||
if code == "temporarily_unavailable":
|
||||
return 503, "the upstream authorization server is temporarily unavailable; retry shortly"
|
||||
return 502, "the upstream authorization server reported an internal error"
|
||||
|
||||
|
||||
def render_token_fault(fault: UpstreamOAuthFault) -> JSONResponse:
|
||||
"""RFC 6749 §5.2 response for a token-endpoint fault. Caller-actionable rejections relay the
|
||||
upstream's code on the status that code implies (401 for invalid_client per §5.2, else 400);
|
||||
gateway-side faults are 502 ``server_error`` with gateway-authored prose so a caller is never
|
||||
blamed for, or shown the internals of, a failure only the operator can fix."""
|
||||
match fault.tag:
|
||||
case "caller_rejected":
|
||||
content = {
|
||||
"error": fault.code,
|
||||
**({"error_description": fault.description} if fault.description else {}),
|
||||
**({"error_uri": fault.error_uri} if fault.error_uri else {}),
|
||||
}
|
||||
status_code = 401 if fault.code == "invalid_client" else 400
|
||||
return JSONResponse(status_code=status_code, content=content, headers=TOKEN_NO_CACHE_HEADERS)
|
||||
case "gateway_rejected":
|
||||
return JSONResponse(
|
||||
status_code=502,
|
||||
content={
|
||||
"error": "server_error",
|
||||
"error_description": _gateway_rejected_description(fault.code),
|
||||
},
|
||||
headers=TOKEN_NO_CACHE_HEADERS,
|
||||
)
|
||||
case "upstream_reported_fault":
|
||||
status_code, description = _upstream_reported_status_and_description(fault.code)
|
||||
return JSONResponse(
|
||||
status_code=status_code,
|
||||
content={"error": fault.code, "error_description": description},
|
||||
headers=TOKEN_NO_CACHE_HEADERS,
|
||||
)
|
||||
case "upstream_protocol_fault":
|
||||
return JSONResponse(
|
||||
status_code=502,
|
||||
content={"error": "server_error", "error_description": fault.note},
|
||||
headers=TOKEN_NO_CACHE_HEADERS,
|
||||
)
|
||||
case _:
|
||||
assert_never(fault.tag)
|
||||
|
||||
|
||||
def dcr_fault_detail(fault: UpstreamOAuthFault) -> tuple[int, str]:
|
||||
"""Status and detail string for a registration fault, raised as HTTPException by the caller.
|
||||
RFC 7591 §3.2.2 defines registration errors as 400, so a contract-conformant rejection is 400
|
||||
regardless of the status the upstream chose; everything else is a 502 upstream fault."""
|
||||
match fault.tag:
|
||||
case "caller_rejected":
|
||||
detail = f"{fault.code}: {fault.description}" if fault.description else fault.code
|
||||
return 400, detail
|
||||
case "gateway_rejected":
|
||||
return 502, _gateway_rejected_description(fault.code)
|
||||
case "upstream_reported_fault":
|
||||
return _upstream_reported_status_and_description(fault.code)
|
||||
case "upstream_protocol_fault":
|
||||
return 502, fault.note
|
||||
case _:
|
||||
assert_never(fault.tag)
|
||||
79
litellm/proxy/_experimental/mcp_server/faults/types.py
Normal file
79
litellm/proxy/_experimental/mcp_server/faults/types.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"""Fault taxonomy for upstream OAuth token and DCR registration failures.
|
||||
|
||||
Each fault is a frozen model on a ``tag`` literal. The tag alone decides the HTTP status, the wire
|
||||
error code, and whose prose the caller sees, so those three facts can never disagree the way they can
|
||||
when an upstream's status and error code are relayed independently.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal, TypeAlias
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
MAX_WIRE_FIELD_CHARS = 500
|
||||
"""Bound on every upstream-derived string that crosses to a caller or into a log line."""
|
||||
|
||||
CredentialSource: TypeAlias = Literal["gateway_stored", "caller_supplied"]
|
||||
"""Whose client credentials the gateway presented upstream: the MCP server's stored configuration or
|
||||
credentials the caller supplied on the request. Decides whether a credential rejection is the
|
||||
caller's problem to fix or the gateway operator's."""
|
||||
|
||||
GATEWAY_CREDENTIAL_CODES: frozenset[str] = frozenset({"invalid_client", "unauthorized_client"})
|
||||
"""RFC 6749 error codes that indict the OAuth client's credentials or grant authorization. When the
|
||||
gateway presented its own stored credentials, these are gateway-side faults the caller cannot act on;
|
||||
when the caller supplied the credentials, they are the caller's to fix."""
|
||||
|
||||
GATEWAY_CAPABILITY_CODES: frozenset[str] = frozenset({"invalid_target"})
|
||||
"""Codes that indict a gateway capability regardless of whose credentials were presented:
|
||||
``invalid_target`` means the upstream wants RFC 8707 resource indicators, which the gateway does not
|
||||
send yet (LIT-4339). Never the caller's fault."""
|
||||
|
||||
UPSTREAM_FAULT_CODES: frozenset[str] = frozenset({"server_error", "temporarily_unavailable"})
|
||||
"""Codes by which the upstream blames itself. Relaying them as caller faults would invert blame, so
|
||||
they classify as upstream-reported faults and render on the 5xx their meaning implies."""
|
||||
|
||||
|
||||
class CallerRejected(BaseModel):
|
||||
"""The upstream spoke the OAuth error contract and the failure is actionable by our caller
|
||||
(e.g. ``invalid_grant``: re-run authorization). The code and its bounded prose relay on the
|
||||
4xx status the code itself implies."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
tag: Literal["caller_rejected"] = "caller_rejected"
|
||||
code: str
|
||||
description: str | None = None
|
||||
error_uri: str | None = None
|
||||
|
||||
|
||||
class GatewayRejected(BaseModel):
|
||||
"""The upstream rejected the request for a cause only the gateway operator can address: the
|
||||
server's stored client credentials or a gateway capability gap. Not actionable by the caller:
|
||||
rendered as 502 with gateway-authored prose naming the code; the upstream's prose goes to
|
||||
server logs only."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
tag: Literal["gateway_rejected"] = "gateway_rejected"
|
||||
code: str
|
||||
|
||||
|
||||
class UpstreamReportedFault(BaseModel):
|
||||
"""The upstream blamed itself in the OAuth vocabulary. Rendered on the 5xx the code implies
|
||||
(``server_error`` 502, ``temporarily_unavailable`` 503) so blame and status agree."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
tag: Literal["upstream_reported_fault"] = "upstream_reported_fault"
|
||||
code: Literal["server_error", "temporarily_unavailable"]
|
||||
|
||||
|
||||
class UpstreamProtocolFault(BaseModel):
|
||||
"""The upstream broke the error contract: no JSON ``error`` field, an undecodable body, or a
|
||||
success response without a usable token. Rendered as 502 with a gateway-authored note; the
|
||||
upstream body never crosses to the caller."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
tag: Literal["upstream_protocol_fault"] = "upstream_protocol_fault"
|
||||
note: str
|
||||
|
||||
|
||||
UpstreamOAuthFault: TypeAlias = CallerRejected | GatewayRejected | UpstreamReportedFault | UpstreamProtocolFault
|
||||
|
|
@ -21,11 +21,16 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import
|
|||
EnvelopeKeys,
|
||||
EnvelopeMintError,
|
||||
OpenedEnvelope,
|
||||
OpenedRefreshEnvelope,
|
||||
RefreshCredential,
|
||||
SealedEnvelope,
|
||||
UpstreamTokenGrant,
|
||||
is_envelope,
|
||||
is_refresh_envelope,
|
||||
mint_envelope,
|
||||
mint_refresh_envelope,
|
||||
open_envelope,
|
||||
open_refresh_envelope,
|
||||
)
|
||||
|
||||
_SIGNING_KEY_DOMAIN = b"litellm-mcp-bridge:envelope-signing:"
|
||||
|
|
@ -92,6 +97,67 @@ def build_bridge_token_response(
|
|||
return mint_envelope(identity, grant, keys, now)
|
||||
|
||||
|
||||
def build_bridge_refresh_token_response(
|
||||
identity: EnvelopeIdentity,
|
||||
refresh: RefreshCredential,
|
||||
keys: EnvelopeKeys,
|
||||
now: datetime,
|
||||
) -> SealedEnvelope | EnvelopeMintError:
|
||||
"""Seal ``refresh`` for ``identity`` into the long-lived refresh envelope the token endpoint returns
|
||||
alongside the access envelope, so the client can renew without re-authenticating. A thin, pure
|
||||
wrapper over :func:`mint_refresh_envelope`; returns the mint error as a value for the caller to map.
|
||||
"""
|
||||
return mint_refresh_envelope(identity, refresh, keys, now)
|
||||
|
||||
|
||||
class BridgeRefreshOpened(BaseModel):
|
||||
"""A valid refresh envelope presented to the token endpoint: the identity to re-validate and renew
|
||||
under, and the upstream refresh grant to exchange."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
tag: Literal["opened"] = "opened"
|
||||
identity: EnvelopeIdentity
|
||||
refresh: RefreshCredential
|
||||
|
||||
|
||||
class BridgeRefreshInvalid(BaseModel):
|
||||
"""The presented refresh grant is not a valid refresh envelope for this server (not refresh-shaped,
|
||||
will not open, or minted for a different server); the token endpoint fails the refresh closed."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
tag: Literal["invalid"] = "invalid"
|
||||
|
||||
|
||||
BridgeRefreshResult: TypeAlias = BridgeRefreshOpened | BridgeRefreshInvalid
|
||||
|
||||
|
||||
def open_bridge_refresh_envelope(
|
||||
refresh_value: str,
|
||||
keys: EnvelopeKeys,
|
||||
now: datetime,
|
||||
expected_server_id: str,
|
||||
) -> BridgeRefreshResult:
|
||||
"""Open a refresh envelope a bridge ``oauth_delegate`` client presented on a refresh_token grant.
|
||||
|
||||
The token-endpoint mirror of :func:`resolve_bridge_envelope`: strips an optional ``Bearer`` scheme,
|
||||
then returns ``BridgeRefreshOpened`` with the recovered identity and upstream refresh grant, or
|
||||
``BridgeRefreshInvalid`` for anything that is not a valid refresh envelope for this server. Never
|
||||
raises; total over hostile input via :func:`open_refresh_envelope`. ``expected_server_id`` binds the
|
||||
envelope to the server the request targets, so a refresh envelope minted for one server cannot renew
|
||||
against another. A raw upstream refresh token (not envelope-shaped) is ``BridgeRefreshInvalid``: this
|
||||
mode never hands the client a bare upstream refresh token, so it must never accept one.
|
||||
"""
|
||||
candidate = _strip_bearer(refresh_value)
|
||||
if not is_refresh_envelope(candidate):
|
||||
return BridgeRefreshInvalid()
|
||||
opened = open_refresh_envelope(candidate, keys, now)
|
||||
if not isinstance(opened, OpenedRefreshEnvelope):
|
||||
return BridgeRefreshInvalid()
|
||||
if opened.identity.server_id != expected_server_id:
|
||||
return BridgeRefreshInvalid()
|
||||
return BridgeRefreshOpened(identity=opened.identity, refresh=opened.refresh)
|
||||
|
||||
|
||||
class NotBridgeEnvelope(BaseModel):
|
||||
"""The bearer is not an envelope; admission continues on its normal path."""
|
||||
|
||||
|
|
@ -128,10 +194,12 @@ def _strip_bearer(value: str) -> str:
|
|||
|
||||
|
||||
def is_bridge_envelope_shaped(authorization_value: str) -> bool:
|
||||
"""Cheap, keyless test that an ``Authorization`` value carries an envelope (optional
|
||||
``Bearer`` scheme stripped). The admission edge engages the bridge arm only for an
|
||||
envelope, so a plain upstream bearer falls through to normal oauth2 admission."""
|
||||
return is_envelope(_strip_bearer(authorization_value))
|
||||
"""Cheap, keyless test that an ``Authorization`` value carries an envelope of either kind (optional
|
||||
``Bearer`` scheme stripped). The admission edge engages the bridge arm for an access envelope (to
|
||||
admit) and for a refresh envelope (to reject it explicitly, since a refresh credential is never
|
||||
usable at the tool-call edge); a plain upstream bearer falls through to normal oauth2 admission."""
|
||||
candidate = _strip_bearer(authorization_value)
|
||||
return is_envelope(candidate) or is_refresh_envelope(candidate)
|
||||
|
||||
|
||||
def resolve_bridge_envelope(
|
||||
|
|
@ -148,6 +216,10 @@ def resolve_bridge_envelope(
|
|||
envelope, and ``BridgeEnvelopeInvalid`` for an envelope-shaped bearer that will not
|
||||
open. Never raises: it is total over hostile input via :func:`open_envelope`.
|
||||
|
||||
A refresh envelope is ``BridgeEnvelopeInvalid`` here: it is a valid gateway credential but only ever
|
||||
presented back to the token endpoint, never usable to authenticate a tool call, so admission must
|
||||
fail it closed rather than let it fall through to another arm.
|
||||
|
||||
``expected_server_id`` is the ``server_id`` of the MCP server the request targets; an
|
||||
opened envelope whose sealed ``server_id`` does not match is rejected as
|
||||
``BridgeEnvelopeInvalid``. Binding here (rather than leaving it to the caller) prevents
|
||||
|
|
@ -157,6 +229,8 @@ def resolve_bridge_envelope(
|
|||
unlike ``hmac.compare_digest`` on ``str``, does not raise on a non-ASCII server_id.
|
||||
"""
|
||||
candidate = _strip_bearer(authorization_value)
|
||||
if is_refresh_envelope(candidate):
|
||||
return BridgeEnvelopeInvalid()
|
||||
if not is_envelope(candidate):
|
||||
return NotBridgeEnvelope()
|
||||
opened = open_envelope(candidate, keys, now)
|
||||
|
|
|
|||
|
|
@ -44,18 +44,33 @@ from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationError
|
|||
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value, encrypt_value
|
||||
|
||||
ENVELOPE_PREFIX = "llm_env_"
|
||||
"""Marker prefix on every serialized envelope so the edge can cheaply tell an envelope
|
||||
"""Marker prefix on every serialized ACCESS envelope so the edge can cheaply tell an envelope
|
||||
from a raw upstream token before doing any cryptography."""
|
||||
|
||||
REFRESH_ENVELOPE_PREFIX = "llm_refresh_"
|
||||
"""Marker prefix on every serialized REFRESH envelope. A distinct prefix keeps the two credentials
|
||||
routable without crypto and, together with the signed ``kind`` claim, stops one from being presented
|
||||
where the other is expected: a refresh envelope carries a long-lived upstream refresh token and is only
|
||||
ever presented back to the token endpoint, never forwarded upstream on a tool call."""
|
||||
|
||||
ENVELOPE_ISSUER = "litellm-mcp-bridge"
|
||||
"""``iss`` claim stamped into every envelope and required back on open."""
|
||||
|
||||
MAX_ENVELOPE_TTL_SECONDS = 3600
|
||||
"""Hard ceiling on envelope lifetime. ``exp`` is ``min(upstream expires_in, this cap)``
|
||||
"""Hard ceiling on ACCESS envelope lifetime. ``exp`` is ``min(upstream expires_in, this cap)``
|
||||
(the cap alone when the upstream omits ``expires_in``), matching the 1h lifetime of the
|
||||
BYOK session bearer this module's signing approach is borrowed from: a client-held
|
||||
credential should never outlive a bounded window even when the upstream token does."""
|
||||
|
||||
MAX_REFRESH_ENVELOPE_TTL_SECONDS = 1209600
|
||||
"""Hard ceiling on REFRESH envelope lifetime (14 days). A refresh envelope only renews the short-lived
|
||||
access envelope, and each renewal re-validates the sealed litellm key (revocation gates it) and is
|
||||
re-minted with a fresh window, so the practical bound is idle time, not a fixed session. ``exp`` is
|
||||
``min(upstream refresh_expires_in, this cap)`` (the cap alone when the upstream omits it); if the
|
||||
upstream refresh token dies first, the next renewal simply fails at the upstream and the client
|
||||
re-authenticates. The value is deliberately far shorter than a typical upstream refresh-token lifetime
|
||||
so a leaked refresh envelope is bounded even if the upstream would have honoured it for longer."""
|
||||
|
||||
MAX_ENVELOPE_BYTES = 12288
|
||||
"""Size cap on the final serialized envelope (prefix + JWT, in bytes). Upstream JWTs
|
||||
commonly run 2-4KB; base64 plus encryption overhead roughly doubles that inside the
|
||||
|
|
@ -66,21 +81,48 @@ typed error, never truncated."""
|
|||
|
||||
_ENVELOPE_JWT_ALGORITHM = "HS256"
|
||||
|
||||
EnvelopeKind = Literal["access", "refresh"]
|
||||
"""Which credential an envelope is. Stamped into the signed claims and required to match on open, so a
|
||||
signature-valid envelope of one kind cannot be replayed as the other even if its wire prefix is swapped
|
||||
(the prefix is not part of the signed payload; this claim is)."""
|
||||
|
||||
|
||||
EnvelopeSubjectType: TypeAlias = Literal["key_hash", "user_id"]
|
||||
"""Discriminator for what litellm principal the envelope binds the grant to.
|
||||
|
||||
``key_hash`` is a hashed virtual key (the scripted two-header client mints under the key it
|
||||
presents at the token endpoint); ``user_id`` is a litellm user subject (the interactive DCR
|
||||
client mints under the SSO-authenticated user, which is the only identity that browser login
|
||||
yields). Admission reloads a key record for the first and a user record for the second, then
|
||||
runs both through the same live-policy gate, so team/org/budget/revocation enforcement is
|
||||
identical either way."""
|
||||
|
||||
|
||||
class EnvelopeIdentity(BaseModel):
|
||||
"""The litellm identity the envelope binds the inner grant to.
|
||||
"""The litellm principal the envelope binds the inner grant to.
|
||||
|
||||
``key_hash`` is the hashed litellm key that authorized the mint, never a raw
|
||||
credential (and the edge rejects a bare hash presented as a bearer). Admission
|
||||
reloads the live key record by it, so the key's current team/org/object-permission
|
||||
restrictions and its revocation state are enforced at use time rather than frozen at
|
||||
mint time. ``server_id`` binds the envelope to one MCP server so it cannot be replayed
|
||||
across a server boundary.
|
||||
``subject`` is the principal identifier and ``subject_type`` says how to resolve it: a
|
||||
hashed litellm key (``key_hash``) or a litellm user id (``user_id``), never a raw
|
||||
credential (and the edge rejects a bare hash or id presented as a bearer). Admission
|
||||
reloads the live record by it, so the principal's current team/org restrictions and its
|
||||
revocation state are enforced at use time rather than frozen at mint time. ``server_id``
|
||||
binds the envelope to one MCP server so it cannot be replayed across a server boundary.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
server_id: str = Field(min_length=1)
|
||||
key_hash: str = Field(min_length=1)
|
||||
subject_type: EnvelopeSubjectType
|
||||
subject: str = Field(min_length=1)
|
||||
|
||||
|
||||
def key_hash_identity(server_id: str, key_hash: str) -> EnvelopeIdentity:
|
||||
"""The identity for the scripted client that mints under a presented virtual key."""
|
||||
return EnvelopeIdentity(server_id=server_id, subject_type="key_hash", subject=key_hash)
|
||||
|
||||
|
||||
def user_identity(server_id: str, user_id: str) -> EnvelopeIdentity:
|
||||
"""The identity for the interactive DCR client that mints under its SSO user subject."""
|
||||
return EnvelopeIdentity(server_id=server_id, subject_type="user_id", subject=user_id)
|
||||
|
||||
|
||||
class UpstreamTokenGrant(BaseModel):
|
||||
|
|
@ -99,6 +141,21 @@ class UpstreamTokenGrant(BaseModel):
|
|||
expires_in: int | None = Field(default=None, gt=0)
|
||||
|
||||
|
||||
class RefreshCredential(BaseModel):
|
||||
"""The upstream refresh grant sealed inside a refresh envelope.
|
||||
|
||||
Only the refresh token (plus the scope to re-request and the refresh token's own lifetime, when the
|
||||
upstream reports it) is sealed; the access token is never in a refresh envelope. ``refresh_token`` is
|
||||
a ``SecretStr`` so reprs never leak it, and ``expires_in`` (the refresh token's lifetime, not the
|
||||
access token's) must be positive when present.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
refresh_token: SecretStr = Field(min_length=1)
|
||||
scope: str | None = None
|
||||
expires_in: int | None = Field(default=None, gt=0)
|
||||
|
||||
|
||||
class EnvelopeKeys(BaseModel):
|
||||
"""Injected key material: the HS256 signing key and the symmetric encryption key.
|
||||
|
||||
|
|
@ -121,13 +178,21 @@ class SealedEnvelope(BaseModel):
|
|||
|
||||
|
||||
class OpenedEnvelope(BaseModel):
|
||||
"""A validated envelope: the identity it was minted for and the recovered grant."""
|
||||
"""A validated access envelope: the identity it was minted for and the recovered grant."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
identity: EnvelopeIdentity
|
||||
grant: UpstreamTokenGrant
|
||||
|
||||
|
||||
class OpenedRefreshEnvelope(BaseModel):
|
||||
"""A validated refresh envelope: the identity it was minted for and the recovered refresh grant."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
identity: EnvelopeIdentity
|
||||
refresh: RefreshCredential
|
||||
|
||||
|
||||
class EnvelopeTooLarge(BaseModel):
|
||||
"""The serialized envelope exceeded ``MAX_ENVELOPE_BYTES``; carries sizes only."""
|
||||
|
||||
|
|
@ -199,8 +264,10 @@ class _EnvelopeClaims(BaseModel):
|
|||
iss: str
|
||||
iat: int
|
||||
exp: int
|
||||
kind: EnvelopeKind
|
||||
server_id: str = Field(min_length=1)
|
||||
key_hash: str = Field(min_length=1)
|
||||
subject_type: EnvelopeSubjectType
|
||||
subject: str = Field(min_length=1)
|
||||
grant: str = Field(min_length=1)
|
||||
|
||||
|
||||
|
|
@ -213,11 +280,25 @@ class _GrantWire(BaseModel):
|
|||
expires_in: int | None = None
|
||||
|
||||
|
||||
class _RefreshWire(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
refresh_token: str
|
||||
scope: str | None = None
|
||||
expires_in: int | None = None
|
||||
|
||||
|
||||
def is_envelope(candidate: str) -> bool:
|
||||
"""Cheap prefix check so the edge can route envelopes vs raw tokens without crypto."""
|
||||
"""Cheap prefix check for an ACCESS envelope so the edge can route envelopes vs raw tokens without
|
||||
crypto. A refresh envelope has a different prefix and is not an access envelope."""
|
||||
return candidate.startswith(ENVELOPE_PREFIX)
|
||||
|
||||
|
||||
def is_refresh_envelope(candidate: str) -> bool:
|
||||
"""Cheap prefix check for a REFRESH envelope so the token endpoint can route a refresh grant that
|
||||
carries an envelope vs a raw upstream refresh token without crypto."""
|
||||
return candidate.startswith(REFRESH_ENVELOPE_PREFIX)
|
||||
|
||||
|
||||
def mint_envelope(
|
||||
identity: EnvelopeIdentity,
|
||||
grant: UpstreamTokenGrant,
|
||||
|
|
@ -231,23 +312,15 @@ def mint_envelope(
|
|||
serialized envelope exceeds ``MAX_ENVELOPE_BYTES``.
|
||||
"""
|
||||
expires_at = now + timedelta(seconds=_envelope_ttl_seconds(grant.expires_in))
|
||||
claims = _EnvelopeClaims(
|
||||
iss=ENVELOPE_ISSUER,
|
||||
iat=int(now.timestamp()),
|
||||
exp=int(expires_at.timestamp()),
|
||||
server_id=identity.server_id,
|
||||
key_hash=identity.key_hash,
|
||||
grant=_encrypt_grant_blob(_grant_plaintext(grant), keys.encryption_key),
|
||||
return _seal(
|
||||
kind="access",
|
||||
prefix=ENVELOPE_PREFIX,
|
||||
identity=identity,
|
||||
grant_blob=_encrypt_grant_blob(_grant_plaintext(grant), keys.encryption_key),
|
||||
expires_at=expires_at,
|
||||
signing_key=keys.signing_key,
|
||||
now=now,
|
||||
)
|
||||
token = ENVELOPE_PREFIX + jwt.encode(
|
||||
claims.model_dump(),
|
||||
keys.signing_key.get_secret_value(),
|
||||
algorithm=_ENVELOPE_JWT_ALGORITHM,
|
||||
)
|
||||
size_bytes = len(token.encode("utf-8"))
|
||||
if size_bytes > MAX_ENVELOPE_BYTES:
|
||||
return EnvelopeTooLarge(size_bytes=size_bytes, max_bytes=MAX_ENVELOPE_BYTES)
|
||||
return SealedEnvelope(token=SecretStr(token), expires_at=expires_at)
|
||||
|
||||
|
||||
def open_envelope(
|
||||
|
|
@ -263,35 +336,136 @@ def open_envelope(
|
|||
re-derived, so it is stale by up to the envelope's lifetime; callers that need a
|
||||
live remaining lifetime should use ``now`` against the upstream, not this field.
|
||||
"""
|
||||
if not is_envelope(candidate):
|
||||
return NotAnEnvelope()
|
||||
# UTF-8 byte length is never below character length, so a character count already over the
|
||||
# cap rejects an oversize candidate in O(1) without encoding it; the exact byte check then
|
||||
# runs only on candidates already bounded to <= MAX_ENVELOPE_BYTES characters.
|
||||
if len(candidate) > MAX_ENVELOPE_BYTES:
|
||||
return MalformedPayload()
|
||||
if len(candidate.encode("utf-8", "surrogatepass")) > MAX_ENVELOPE_BYTES:
|
||||
return MalformedPayload()
|
||||
claims = _decode_claims(candidate.removeprefix(ENVELOPE_PREFIX), keys.signing_key)
|
||||
claims = _open_claims(candidate, prefix=ENVELOPE_PREFIX, expected_kind="access", keys=keys, now=now)
|
||||
if not isinstance(claims, _EnvelopeClaims):
|
||||
return claims
|
||||
if now.timestamp() >= claims.exp:
|
||||
return Expired()
|
||||
grant = _decrypt_grant(claims.grant, keys.encryption_key)
|
||||
if not isinstance(grant, UpstreamTokenGrant):
|
||||
return grant
|
||||
return OpenedEnvelope(
|
||||
identity=EnvelopeIdentity(server_id=claims.server_id, key_hash=claims.key_hash),
|
||||
identity=EnvelopeIdentity(server_id=claims.server_id, subject_type=claims.subject_type, subject=claims.subject),
|
||||
grant=grant,
|
||||
)
|
||||
|
||||
|
||||
def mint_refresh_envelope(
|
||||
identity: EnvelopeIdentity,
|
||||
refresh: RefreshCredential,
|
||||
keys: EnvelopeKeys,
|
||||
now: datetime,
|
||||
) -> SealedEnvelope | EnvelopeMintError:
|
||||
"""Seal ``refresh`` for ``identity`` into a long-lived, client-held refresh envelope.
|
||||
|
||||
``exp`` is ``min(refresh.expires_in, MAX_REFRESH_ENVELOPE_TTL_SECONDS)`` seconds from ``now`` (the
|
||||
cap alone when the upstream omits the refresh lifetime). Sealing a distinct ``kind="refresh"`` claim
|
||||
is what keeps a refresh envelope from ever opening as an access credential at the MCP edge. Returns
|
||||
``EnvelopeTooLarge`` when the serialized envelope exceeds ``MAX_ENVELOPE_BYTES``.
|
||||
"""
|
||||
expires_at = now + timedelta(seconds=_refresh_ttl_seconds(refresh.expires_in))
|
||||
return _seal(
|
||||
kind="refresh",
|
||||
prefix=REFRESH_ENVELOPE_PREFIX,
|
||||
identity=identity,
|
||||
grant_blob=_encrypt_grant_blob(_refresh_plaintext(refresh), keys.encryption_key),
|
||||
expires_at=expires_at,
|
||||
signing_key=keys.signing_key,
|
||||
now=now,
|
||||
)
|
||||
|
||||
|
||||
def open_refresh_envelope(
|
||||
candidate: str,
|
||||
keys: EnvelopeKeys,
|
||||
now: datetime,
|
||||
) -> OpenedRefreshEnvelope | EnvelopeOpenError:
|
||||
"""Validate a refresh ``candidate`` and recover the identity and inner refresh grant.
|
||||
|
||||
Total over hostile input exactly like :func:`open_envelope`: every invalid, expired, tampered,
|
||||
wrong-kind, or undecryptable candidate maps to a distinct ``EnvelopeOpenError`` variant, never a
|
||||
raise. The ``kind="refresh"`` claim is required, so an access envelope re-prefixed as a refresh one
|
||||
is rejected as ``MalformedPayload``.
|
||||
"""
|
||||
claims = _open_claims(candidate, prefix=REFRESH_ENVELOPE_PREFIX, expected_kind="refresh", keys=keys, now=now)
|
||||
if not isinstance(claims, _EnvelopeClaims):
|
||||
return claims
|
||||
refresh = _decrypt_refresh(claims.grant, keys.encryption_key)
|
||||
if not isinstance(refresh, RefreshCredential):
|
||||
return refresh
|
||||
return OpenedRefreshEnvelope(
|
||||
identity=EnvelopeIdentity(server_id=claims.server_id, subject_type=claims.subject_type, subject=claims.subject),
|
||||
refresh=refresh,
|
||||
)
|
||||
|
||||
|
||||
def _seal(
|
||||
kind: EnvelopeKind,
|
||||
prefix: str,
|
||||
identity: EnvelopeIdentity,
|
||||
grant_blob: str,
|
||||
expires_at: datetime,
|
||||
signing_key: SecretStr,
|
||||
now: datetime,
|
||||
) -> SealedEnvelope | EnvelopeTooLarge:
|
||||
"""Sign the claims for either envelope kind and enforce the size cap. Shared by both mints so the
|
||||
JWT shape, issuer, and size guard cannot drift between access and refresh envelopes."""
|
||||
claims = _EnvelopeClaims(
|
||||
iss=ENVELOPE_ISSUER,
|
||||
iat=int(now.timestamp()),
|
||||
exp=int(expires_at.timestamp()),
|
||||
kind=kind,
|
||||
server_id=identity.server_id,
|
||||
subject_type=identity.subject_type,
|
||||
subject=identity.subject,
|
||||
grant=grant_blob,
|
||||
)
|
||||
token = prefix + jwt.encode(claims.model_dump(), signing_key.get_secret_value(), algorithm=_ENVELOPE_JWT_ALGORITHM)
|
||||
size_bytes = len(token.encode("utf-8"))
|
||||
if size_bytes > MAX_ENVELOPE_BYTES:
|
||||
return EnvelopeTooLarge(size_bytes=size_bytes, max_bytes=MAX_ENVELOPE_BYTES)
|
||||
return SealedEnvelope(token=SecretStr(token), expires_at=expires_at)
|
||||
|
||||
|
||||
def _open_claims(
|
||||
candidate: str,
|
||||
prefix: str,
|
||||
expected_kind: EnvelopeKind,
|
||||
keys: EnvelopeKeys,
|
||||
now: datetime,
|
||||
) -> _EnvelopeClaims | EnvelopeOpenError:
|
||||
"""Prefix-route, size-bound, signature-verify, kind-check, and expiry-check an attacker-controlled
|
||||
candidate, shared by both openers so the security gate is identical for access and refresh. Returns
|
||||
the validated claims or a distinct ``EnvelopeOpenError``; never raises."""
|
||||
if not candidate.startswith(prefix):
|
||||
return NotAnEnvelope()
|
||||
# UTF-8 byte length is never below character length, so a character count already over the cap
|
||||
# rejects an oversize candidate in O(1) without encoding it; the exact byte check then runs only on
|
||||
# candidates already bounded to <= MAX_ENVELOPE_BYTES characters.
|
||||
if len(candidate) > MAX_ENVELOPE_BYTES:
|
||||
return MalformedPayload()
|
||||
if len(candidate.encode("utf-8", "surrogatepass")) > MAX_ENVELOPE_BYTES:
|
||||
return MalformedPayload()
|
||||
claims = _decode_claims(candidate.removeprefix(prefix), keys.signing_key)
|
||||
if not isinstance(claims, _EnvelopeClaims):
|
||||
return claims
|
||||
if claims.kind != expected_kind:
|
||||
return MalformedPayload()
|
||||
if now.timestamp() >= claims.exp:
|
||||
return Expired()
|
||||
return claims
|
||||
|
||||
|
||||
def _envelope_ttl_seconds(upstream_expires_in: int | None) -> int:
|
||||
if upstream_expires_in is None:
|
||||
return MAX_ENVELOPE_TTL_SECONDS
|
||||
return min(upstream_expires_in, MAX_ENVELOPE_TTL_SECONDS)
|
||||
|
||||
|
||||
def _refresh_ttl_seconds(upstream_refresh_expires_in: int | None) -> int:
|
||||
if upstream_refresh_expires_in is None:
|
||||
return MAX_REFRESH_ENVELOPE_TTL_SECONDS
|
||||
return min(upstream_refresh_expires_in, MAX_REFRESH_ENVELOPE_TTL_SECONDS)
|
||||
|
||||
|
||||
def _grant_plaintext(grant: UpstreamTokenGrant) -> str:
|
||||
wire = _GrantWire(
|
||||
access_token=grant.access_token.get_secret_value(),
|
||||
|
|
@ -303,6 +477,15 @@ def _grant_plaintext(grant: UpstreamTokenGrant) -> str:
|
|||
return wire.model_dump_json(exclude_none=True)
|
||||
|
||||
|
||||
def _refresh_plaintext(refresh: RefreshCredential) -> str:
|
||||
wire = _RefreshWire(
|
||||
refresh_token=refresh.refresh_token.get_secret_value(),
|
||||
scope=refresh.scope,
|
||||
expires_in=refresh.expires_in,
|
||||
)
|
||||
return wire.model_dump_json(exclude_none=True)
|
||||
|
||||
|
||||
def _decode_claims(
|
||||
compact: str,
|
||||
signing_key: SecretStr,
|
||||
|
|
@ -364,3 +547,22 @@ def _decrypt_grant(
|
|||
return UpstreamTokenGrant.model_validate_json(plaintext)
|
||||
except ValidationError:
|
||||
return MalformedPayload()
|
||||
|
||||
|
||||
def _decrypt_refresh(
|
||||
blob: str,
|
||||
encryption_key: SecretStr,
|
||||
) -> RefreshCredential | DecryptFailed | MalformedPayload:
|
||||
from nacl.exceptions import CryptoError
|
||||
|
||||
try:
|
||||
plaintext = decrypt_value(
|
||||
value=base64.urlsafe_b64decode(blob),
|
||||
signing_key=encryption_key.get_secret_value(),
|
||||
)
|
||||
except (CryptoError, ValueError):
|
||||
return DecryptFailed()
|
||||
try:
|
||||
return RefreshCredential.model_validate_json(plaintext)
|
||||
except ValidationError:
|
||||
return MalformedPayload()
|
||||
|
|
|
|||
|
|
@ -1118,6 +1118,7 @@ class GenerateKeyRequest(KeyRequestBase):
|
|||
class GenerateKeyResponse(KeyRequestBase):
|
||||
key: str # type: ignore
|
||||
key_name: Optional[str] = None
|
||||
key_type: str | None = None
|
||||
expires: Optional[datetime] = None
|
||||
user_id: Optional[str] = None
|
||||
token_id: Optional[str] = None
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ from litellm.proxy._types import (
|
|||
)
|
||||
from litellm.secret_managers.main import str_to_bool
|
||||
|
||||
# Bounds the __cause__/__context__ walk in is_database_service_unavailable_error_in_chain.
|
||||
# Real exception chains are a few links deep; the cap also makes the walk cycle-safe.
|
||||
_MAX_EXCEPTION_CHAIN_DEPTH = 20
|
||||
|
||||
|
||||
class PrismaDBExceptionHandler:
|
||||
"""
|
||||
|
|
@ -218,6 +222,32 @@ class PrismaDBExceptionHandler:
|
|||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def is_database_service_unavailable_error_in_chain(e: BaseException) -> bool:
|
||||
"""Like ``is_database_service_unavailable_error`` but also walks the
|
||||
``__cause__`` / ``__context__`` chain.
|
||||
|
||||
``is_database_service_unavailable_error`` classifies a single exception
|
||||
by type, which a caller that catches a raw DB failure and re-raises a
|
||||
domain exception of a different type defeats. ``get_user_object`` in
|
||||
``litellm/proxy/auth/auth_checks.py`` is the concrete case: it wraps
|
||||
every DB error, a genuine outage included, in a bare ``ValueError``
|
||||
whose original error survives only as ``__context__``. A type check on
|
||||
the ``ValueError`` misses the outage, so the caller would mistake an
|
||||
infrastructure fault for an auth failure. Walking the chain recovers the
|
||||
real signal, which is the PEP 3134 way to inspect a wrapped cause.
|
||||
|
||||
The walk is depth-bounded, which also makes it cycle-safe.
|
||||
"""
|
||||
current: BaseException | None = e
|
||||
for _ in range(_MAX_EXCEPTION_CHAIN_DEPTH):
|
||||
if not isinstance(current, Exception):
|
||||
return False
|
||||
if PrismaDBExceptionHandler.is_database_service_unavailable_error(current):
|
||||
return True
|
||||
current = current.__cause__ or current.__context__
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def handle_db_exception(e: Exception):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -468,7 +468,10 @@ def handle_key_type(data: GenerateKeyRequest, data_json: dict) -> dict:
|
|||
Handle the key type.
|
||||
"""
|
||||
key_type = data.key_type
|
||||
data_json.pop("key_type", None)
|
||||
if key_type is None:
|
||||
data_json.pop("key_type", None)
|
||||
return data_json
|
||||
data_json["key_type"] = key_type.value
|
||||
if key_type == LiteLLMKeyType.LLM_API:
|
||||
data_json["allowed_routes"] = ["llm_api_routes"]
|
||||
elif key_type == LiteLLMKeyType.MANAGEMENT:
|
||||
|
|
@ -3566,6 +3569,7 @@ async def generate_key_helper_fn(
|
|||
created_by: Optional[str] = None,
|
||||
updated_by: Optional[str] = None,
|
||||
allowed_routes: Optional[list] = None,
|
||||
key_type: str | None = None,
|
||||
sso_user_id: Optional[str] = None,
|
||||
object_permission_id: Optional[str] = None, # object_permission_id <-> LiteLLM_ObjectPermissionTable
|
||||
object_permission: Optional[LiteLLM_ObjectPermissionBase] = None,
|
||||
|
|
@ -3706,6 +3710,7 @@ async def generate_key_helper_fn(
|
|||
"created_by": created_by,
|
||||
"updated_by": updated_by,
|
||||
"allowed_routes": allowed_routes or [],
|
||||
"key_type": key_type,
|
||||
"object_permission_id": object_permission_id,
|
||||
"router_settings": router_settings_json,
|
||||
"access_group_ids": access_group_ids or [],
|
||||
|
|
|
|||
|
|
@ -422,6 +422,7 @@ model LiteLLM_VerificationToken {
|
|||
budget_reset_at DateTime?
|
||||
allowed_cache_controls String[] @default([])
|
||||
allowed_routes String[] @default([])
|
||||
key_type String?
|
||||
policies String[] @default([])
|
||||
access_group_ids String[] @default([])
|
||||
model_spend Json @default("{}")
|
||||
|
|
@ -516,6 +517,7 @@ model LiteLLM_DeletedVerificationToken {
|
|||
budget_reset_at DateTime?
|
||||
allowed_cache_controls String[] @default([])
|
||||
allowed_routes String[] @default([])
|
||||
key_type String?
|
||||
policies String[] @default([])
|
||||
access_group_ids String[] @default([])
|
||||
model_spend Json @default("{}")
|
||||
|
|
|
|||
|
|
@ -7605,13 +7605,13 @@ class Router:
|
|||
model_name = entry.get("model_name") if isinstance(entry, dict) else entry.model_name
|
||||
if not model_name or not lp:
|
||||
continue
|
||||
if model_name in self.adaptive_routers:
|
||||
continue
|
||||
deployment = Deployment(
|
||||
model_name=model_name,
|
||||
litellm_params=(lp if not isinstance(lp, dict) else LiteLLM_Params(**lp)),
|
||||
model_info=(entry.get("model_info") if isinstance(entry, dict) else entry.model_info),
|
||||
)
|
||||
if model_name in self.adaptive_routers:
|
||||
continue
|
||||
self.init_adaptive_router_deployment(deployment=deployment)
|
||||
|
||||
for model_name, complexity_router in self.complexity_routers.items():
|
||||
|
|
@ -10707,56 +10707,39 @@ class Router:
|
|||
if self.routing_plugins:
|
||||
await self._run_routing_plugins(model=model, request_kwargs=request_kwargs, messages=messages)
|
||||
|
||||
#########################################################
|
||||
# Check if any auto-router should be used
|
||||
#########################################################
|
||||
if model in self.auto_routers:
|
||||
return await self.auto_routers[model].async_pre_routing_hook(
|
||||
model=model,
|
||||
request_kwargs=request_kwargs,
|
||||
messages=messages,
|
||||
input=input,
|
||||
specific_deployment=specific_deployment,
|
||||
)
|
||||
router_strategy = (
|
||||
self.auto_routers.get(model)
|
||||
or self.complexity_routers.get(model)
|
||||
or self.adaptive_routers.get(model)
|
||||
or self.quality_routers.get(model)
|
||||
)
|
||||
if router_strategy is None:
|
||||
return None
|
||||
|
||||
#########################################################
|
||||
# Check if any complexity-router should be used
|
||||
#########################################################
|
||||
if model in self.complexity_routers:
|
||||
return await self.complexity_routers[model].async_pre_routing_hook(
|
||||
model=model,
|
||||
request_kwargs=request_kwargs,
|
||||
messages=messages,
|
||||
input=input,
|
||||
specific_deployment=specific_deployment,
|
||||
)
|
||||
pre_routing_hook_response = await router_strategy.async_pre_routing_hook(
|
||||
model=model,
|
||||
request_kwargs=request_kwargs,
|
||||
messages=messages,
|
||||
input=input,
|
||||
specific_deployment=specific_deployment,
|
||||
)
|
||||
|
||||
#########################################################
|
||||
# Check if an adaptive-router should be used
|
||||
#########################################################
|
||||
adaptive_router = self.adaptive_routers.get(model)
|
||||
if adaptive_router is not None:
|
||||
return await adaptive_router.async_pre_routing_hook(
|
||||
model=model,
|
||||
request_kwargs=request_kwargs,
|
||||
messages=messages,
|
||||
input=input,
|
||||
specific_deployment=specific_deployment,
|
||||
)
|
||||
# `model` (the alias, e.g. "smart-router") is never the deployment actually
|
||||
# called - apply the alias's own litellm_params (besides `model` itself,
|
||||
# which is just the alias marker) to the request, since the tier/route
|
||||
# deployment the hook selected won't have them. Router-only fields
|
||||
# (tpm, rpm, weight, complexity_router_config, ...) are excluded from the
|
||||
# actual outbound LLM call downstream by litellm.types.utils.all_litellm_params,
|
||||
# not here.
|
||||
if pre_routing_hook_response is not None:
|
||||
alias_index = self.model_name_to_deployment_indices.get(model, [])
|
||||
if alias_index:
|
||||
alias_litellm_params = self.model_list[alias_index[0]].get("litellm_params", {})
|
||||
for key, value in alias_litellm_params.items():
|
||||
if key != "model" and value is not None:
|
||||
request_kwargs.setdefault(key, value)
|
||||
|
||||
#########################################################
|
||||
# Check if any quality-router should be used
|
||||
#########################################################
|
||||
if model in self.quality_routers:
|
||||
return await self.quality_routers[model].async_pre_routing_hook(
|
||||
model=model,
|
||||
request_kwargs=request_kwargs,
|
||||
messages=messages,
|
||||
input=input,
|
||||
specific_deployment=specific_deployment,
|
||||
)
|
||||
|
||||
return None
|
||||
return pre_routing_hook_response
|
||||
|
||||
def get_available_deployment(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ from __future__ import annotations
|
|||
import asyncio
|
||||
import random
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any, Literal, Optional, Union, cast
|
||||
from typing import TYPE_CHECKING, Any, Literal, Union, cast
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
|
@ -809,6 +809,44 @@ class ComplexityRouter(CustomLogger):
|
|||
|
||||
return user_message, system_prompt
|
||||
|
||||
@staticmethod
|
||||
def _iter_metadata_dicts(request_kwargs: dict) -> list[dict]:
|
||||
"""Metadata may land on `metadata` or `litellm_metadata` depending on the
|
||||
endpoint, mirroring DeploymentAffinityCheck's precedence."""
|
||||
return [
|
||||
metadata
|
||||
for metadata_key in ("litellm_metadata", "metadata")
|
||||
if isinstance(metadata := request_kwargs.get(metadata_key), dict)
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _get_session_id_from_request_kwargs(request_kwargs: dict) -> str | None:
|
||||
"""Resolve a client-supplied session_id."""
|
||||
for metadata in ComplexityRouter._iter_metadata_dicts(request_kwargs):
|
||||
session_id = metadata.get("session_id")
|
||||
if session_id is not None:
|
||||
return str(session_id)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _get_user_api_key_hash_from_request_kwargs(request_kwargs: dict) -> str | None:
|
||||
"""Resolve the proxy-derived API key hash, the same trust boundary
|
||||
DeploymentAffinityCheck uses for its own key-based affinity (not the
|
||||
client-supplied OpenAI `user` param, which isn't authenticated)."""
|
||||
for metadata in ComplexityRouter._iter_metadata_dicts(request_kwargs):
|
||||
user_key = metadata.get("user_api_key_hash")
|
||||
if user_key is not None:
|
||||
return str(user_key)
|
||||
return None
|
||||
|
||||
def _get_session_affinity_cache_key(self, session_id: str, request_kwargs: dict) -> str:
|
||||
# Namespace by the caller's API key hash so two different callers reusing the
|
||||
# same client-supplied session_id can't poison each other's routing pin. Falls
|
||||
# back to "unscoped" only when there's no authenticated caller to scope by
|
||||
# (e.g. direct Router usage without the proxy layer).
|
||||
caller_scope = self._get_user_api_key_hash_from_request_kwargs(request_kwargs) or "unscoped"
|
||||
return f"complexity_router_session_affinity:v1:{self.model_name}:{caller_scope}:{session_id}"
|
||||
|
||||
async def async_pre_routing_hook(
|
||||
self,
|
||||
model: str,
|
||||
|
|
@ -816,10 +854,70 @@ class ComplexityRouter(CustomLogger):
|
|||
messages: list[dict[str, Any]] | None = None,
|
||||
input: Union[str, list] | None = None,
|
||||
specific_deployment: bool | None = False,
|
||||
) -> Optional[PreRoutingHookResponse]:
|
||||
) -> PreRoutingHookResponse | None:
|
||||
"""
|
||||
Pre-routing hook called before the routing decision.
|
||||
|
||||
When `session_affinity` is enabled and a session_id is resolvable on the request,
|
||||
pins the model chosen on the session's first turn and reuses it for every later
|
||||
turn, skipping classification entirely. Otherwise delegates to `_classify_and_route`.
|
||||
"""
|
||||
from litellm.types.router import PreRoutingHookResponse
|
||||
|
||||
session_id = self._get_session_id_from_request_kwargs(request_kwargs) if self.config.session_affinity else None
|
||||
cache_key = self._get_session_affinity_cache_key(session_id, request_kwargs) if session_id is not None else None
|
||||
|
||||
if cache_key is not None:
|
||||
pinned_model = await self.litellm_router_instance.cache.async_get_cache(key=cache_key)
|
||||
if isinstance(pinned_model, str):
|
||||
# Refresh the TTL on every hit so an active session doesn't lose its
|
||||
# pin mid-conversation just because it outlives the original write.
|
||||
await self.litellm_router_instance.cache.async_set_cache(
|
||||
key=cache_key,
|
||||
value=pinned_model,
|
||||
ttl=self.config.session_affinity_ttl_seconds,
|
||||
)
|
||||
if self.config.adaptive:
|
||||
from litellm.router_strategy.adaptive_router.config import (
|
||||
ADAPTIVE_ROUTER_CHOSEN_MODEL_KEY,
|
||||
)
|
||||
|
||||
kwargs_metadata = request_kwargs.setdefault("metadata", {})
|
||||
if isinstance(kwargs_metadata, dict):
|
||||
kwargs_metadata[ADAPTIVE_ROUTER_CHOSEN_MODEL_KEY] = pinned_model
|
||||
verbose_router_logger.info(
|
||||
f"ComplexityRouter: routing decision cause=session_affinity_pin, routed_model={pinned_model}"
|
||||
)
|
||||
has_original_messages = messages is not None and len(messages) > 0
|
||||
return PreRoutingHookResponse(
|
||||
model=pinned_model,
|
||||
messages=messages if has_original_messages else None,
|
||||
)
|
||||
|
||||
response = await self._classify_and_route(
|
||||
model=model,
|
||||
request_kwargs=request_kwargs,
|
||||
messages=messages,
|
||||
input=input,
|
||||
specific_deployment=specific_deployment,
|
||||
)
|
||||
if cache_key is not None and response is not None:
|
||||
await self.litellm_router_instance.cache.async_set_cache(
|
||||
key=cache_key,
|
||||
value=response.model,
|
||||
ttl=self.config.session_affinity_ttl_seconds,
|
||||
)
|
||||
return response
|
||||
|
||||
async def _classify_and_route(
|
||||
self,
|
||||
model: str,
|
||||
request_kwargs: dict,
|
||||
messages: list[dict[str, Any]] | None = None,
|
||||
input: Union[str, list] | None = None,
|
||||
specific_deployment: bool | None = False,
|
||||
) -> PreRoutingHookResponse | None:
|
||||
"""
|
||||
Classifies the request by complexity and returns the appropriate model.
|
||||
Supports chat completions (messages), Responses API (input), and other
|
||||
formats via the guardrail translation handler dispatch.
|
||||
|
|
|
|||
|
|
@ -361,6 +361,20 @@ class ComplexityRouterConfig(BaseModel):
|
|||
description="Minimum cosine similarity for a semantic keyword match",
|
||||
)
|
||||
|
||||
# Session affinity: pin the first turn's routed model for the rest of the session
|
||||
session_affinity: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"When True and a session_id is resolvable on the request, pin the model chosen on the "
|
||||
"session's first turn and reuse it for every later turn, skipping re-classification."
|
||||
),
|
||||
)
|
||||
session_affinity_ttl_seconds: int = Field(
|
||||
default=3600,
|
||||
gt=0,
|
||||
description="TTL for the session affinity pin; refreshed on every cache hit",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(extra="allow") # Allow additional fields
|
||||
|
||||
@field_validator("tiers", mode="before")
|
||||
|
|
|
|||
|
|
@ -213,6 +213,8 @@ DEFINED_PROMETHEUS_METRICS = Literal[
|
|||
"litellm_input_audio_tokens_metric",
|
||||
"litellm_output_reasoning_tokens_metric",
|
||||
"litellm_output_audio_tokens_metric",
|
||||
"litellm_video_duration_seconds_metric",
|
||||
"litellm_images_generated_metric",
|
||||
"litellm_deployment_successful_fallbacks",
|
||||
"litellm_deployment_failed_fallbacks",
|
||||
"litellm_remaining_team_budget_metric",
|
||||
|
|
@ -506,6 +508,9 @@ class PrometheusMetricLabels:
|
|||
litellm_output_reasoning_tokens_metric = litellm_output_tokens_metric
|
||||
litellm_output_audio_tokens_metric = litellm_output_tokens_metric
|
||||
|
||||
litellm_video_duration_seconds_metric = litellm_output_tokens_metric
|
||||
litellm_images_generated_metric = litellm_output_tokens_metric
|
||||
|
||||
litellm_deployment_state = [
|
||||
UserAPIKeyLabelNames.v2_LITELLM_MODEL_NAME.value,
|
||||
UserAPIKeyLabelNames.MODEL_ID.value,
|
||||
|
|
@ -717,6 +722,8 @@ class PrometheusMetricLabels:
|
|||
"litellm_input_tokens_metric",
|
||||
"litellm_total_tokens_metric",
|
||||
"litellm_output_tokens_metric",
|
||||
"litellm_video_duration_seconds_metric",
|
||||
"litellm_images_generated_metric",
|
||||
}
|
||||
)
|
||||
# Managed batch metrics
|
||||
|
|
|
|||
|
|
@ -3210,6 +3210,16 @@ all_litellm_params = (
|
|||
"_litellm_tpm_reserved_model",
|
||||
"_litellm_tpm_reserved_scopes",
|
||||
"_litellm_tpm_reservation_released",
|
||||
"auto_router_config_path",
|
||||
"auto_router_config",
|
||||
"auto_router_default_model",
|
||||
"auto_router_embedding_model",
|
||||
"complexity_router_config",
|
||||
"complexity_router_default_model",
|
||||
"adaptive_router_config",
|
||||
"adaptive_router_default_model",
|
||||
"quality_router_config",
|
||||
"quality_router_default_model",
|
||||
]
|
||||
+ list(StandardCallbackDynamicParams.__annotations__.keys())
|
||||
+ list(CustomPricingLiteLLMParams.model_fields.keys())
|
||||
|
|
|
|||
|
|
@ -422,6 +422,7 @@ model LiteLLM_VerificationToken {
|
|||
budget_reset_at DateTime?
|
||||
allowed_cache_controls String[] @default([])
|
||||
allowed_routes String[] @default([])
|
||||
key_type String?
|
||||
policies String[] @default([])
|
||||
access_group_ids String[] @default([])
|
||||
model_spend Json @default("{}")
|
||||
|
|
@ -516,6 +517,7 @@ model LiteLLM_DeletedVerificationToken {
|
|||
budget_reset_at DateTime?
|
||||
allowed_cache_controls String[] @default([])
|
||||
allowed_routes String[] @default([])
|
||||
key_type String?
|
||||
policies String[] @default([])
|
||||
access_group_ids String[] @default([])
|
||||
model_spend Json @default("{}")
|
||||
|
|
|
|||
|
|
@ -18,6 +18,12 @@ configs:
|
|||
type: redis
|
||||
host: redis
|
||||
port: 6379
|
||||
# OTEL v2 trace destination for the logging suite's trace-completeness
|
||||
# tests: the arize_phoenix preset is OTLP with a configurable endpoint
|
||||
# (PHOENIX_COLLECTOR_HTTP_ENDPOINT below points it at the jaeger service),
|
||||
# so gen-AI spans export through a preset-owned provider - the code path
|
||||
# where trace splits actually happen - with no cloud credentials needed.
|
||||
callbacks: ["arize_phoenix"]
|
||||
|
||||
router_settings:
|
||||
routing_strategy: simple-shuffle
|
||||
|
|
@ -68,9 +74,14 @@ services:
|
|||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
jaeger:
|
||||
condition: service_healthy
|
||||
env_file: .env
|
||||
environment:
|
||||
LITELLM_MASTER_KEY: sk-1234
|
||||
LITELLM_OTEL_V2: "true"
|
||||
PHOENIX_COLLECTOR_HTTP_ENDPOINT: http://jaeger:4318/v1/traces
|
||||
PHOENIX_API_KEY: local-jaeger-noauth
|
||||
DATABASE_URL: postgresql://litellm:litellm@db:5432/litellm
|
||||
UI_USERNAME: admin
|
||||
UI_PASSWORD: sk-1234
|
||||
|
|
@ -114,3 +125,15 @@ services:
|
|||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
|
||||
# throwaway OTEL trace destination (OTLP ingest on 4318 inside the network,
|
||||
# query API on host 16686 for test read-back; see E2E_OTEL_QUERY_URL)
|
||||
jaeger:
|
||||
image: jaegertracing/all-in-one:1.62.0
|
||||
ports:
|
||||
- "16686:16686"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:14269/"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
|
|
|
|||
|
|
@ -24,6 +24,13 @@ CONTROL_PLANE_BASE_URL = os.environ.get(
|
|||
UI_USERNAME = os.environ.get("E2E_UI_USERNAME", "admin")
|
||||
UI_PASSWORD = os.environ.get("E2E_UI_PASSWORD", MASTER_KEY)
|
||||
|
||||
CHEAP_ANTHROPIC_MODEL = os.environ.get("E2E_CHEAP_ANTHROPIC_MODEL", "claude-haiku-4-5")
|
||||
|
||||
# Jaeger query API of the compose stack's OTEL trace destination (the `jaeger`
|
||||
# service in docker-compose.yml maps it to host 16686). Trace-completeness tests
|
||||
# read exported spans back through it.
|
||||
OTEL_QUERY_URL = os.environ.get("E2E_OTEL_QUERY_URL", "http://localhost:16686").rstrip("/")
|
||||
|
||||
# Writes on the proxy are eventually consistent (e.g. spend rows flush on
|
||||
# proxy_batch_write_at, ~60s). Read-backs poll to this deadline, never sleep-once.
|
||||
POLL_TIMEOUT = float(os.environ.get("E2E_POLL_TIMEOUT", "120"))
|
||||
|
|
|
|||
|
|
@ -11,14 +11,8 @@ from collections.abc import Iterator
|
|||
|
||||
import pytest
|
||||
|
||||
from logging_client import (
|
||||
LangfuseCreds,
|
||||
LoggingClient,
|
||||
PhoenixCreds,
|
||||
build_logging_client,
|
||||
load_langfuse_creds,
|
||||
load_phoenix_creds,
|
||||
)
|
||||
from logging_client import LangfuseCreds, LoggingClient, build_logging_client, load_langfuse_creds
|
||||
from otel_client import OtelReader, build_otel_reader
|
||||
|
||||
|
||||
def pytest_configure(config: pytest.Config) -> None:
|
||||
|
|
@ -39,6 +33,12 @@ def client() -> Iterator[LoggingClient]:
|
|||
model_cleanup.teardown()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def otel_reader() -> OtelReader:
|
||||
"""Read-back client for the compose stack's Jaeger trace destination."""
|
||||
return build_otel_reader()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def datadog_creds() -> None:
|
||||
"""Require Datadog shipping credentials. Hard-fail when absent; never skip."""
|
||||
|
|
|
|||
138
tests/e2e/logging/otel_client.py
Normal file
138
tests/e2e/logging/otel_client.py
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
"""Jaeger read-back for the OTEL trace-completeness tests: typed models over the
|
||||
Jaeger query API (the destination's own API - completeness is judged on what the
|
||||
backend actually holds, never on "export succeeded" proxy-side).
|
||||
|
||||
Traces are fetched server-side by the ``litellm.call_id`` tag the gen-AI span
|
||||
carries (the request's x-litellm-call-id response header), so read-back is
|
||||
immune to the query page filling up with unrelated traffic (background jobs,
|
||||
other suites sharing the stack). Jaeger returns every span of a matching trace,
|
||||
so the completeness assertions see the whole tree. A failed query is a hard
|
||||
failure, never an empty result - an unreachable destination must not read as
|
||||
"the trace never arrived".
|
||||
|
||||
External reads go through ``e2e_http`` (the only module allowed to call
|
||||
``requests.*``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from e2e_config import OTEL_QUERY_URL, POLL_INTERVAL, POLL_TIMEOUT
|
||||
from e2e_http import URL, NoBody, Success, get
|
||||
|
||||
#: OTEL resource service.name the proxy exports under (OTEL_SERVICE_NAME default).
|
||||
JAEGER_SERVICE = "litellm"
|
||||
#: Span tag carrying the request's x-litellm-call-id (stamped on the gen-AI span).
|
||||
CALL_ID_TAG = "litellm.call_id"
|
||||
|
||||
|
||||
class JaegerTag(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
key: str
|
||||
value: str | int | float | bool | None = None
|
||||
|
||||
|
||||
class JaegerReference(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
|
||||
ref_type: str = Field(alias="refType")
|
||||
trace_id: str = Field(alias="traceID")
|
||||
span_id: str = Field(alias="spanID")
|
||||
|
||||
|
||||
class JaegerSpan(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
|
||||
span_id: str = Field(alias="spanID")
|
||||
operation_name: str = Field(alias="operationName")
|
||||
start_time: int = Field(default=0, alias="startTime")
|
||||
references: list[JaegerReference] = []
|
||||
tags: list[JaegerTag] = []
|
||||
|
||||
@property
|
||||
def kind(self) -> str:
|
||||
for tag in self.tags:
|
||||
if tag.key == "span.kind":
|
||||
return str(tag.value)
|
||||
return ""
|
||||
|
||||
|
||||
class JaegerTrace(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
|
||||
trace_id: str = Field(alias="traceID")
|
||||
spans: list[JaegerSpan] = []
|
||||
|
||||
def span_names(self) -> list[str]:
|
||||
return sorted(span.operation_name for span in self.spans)
|
||||
|
||||
|
||||
class JaegerTracesPage(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
data: list[JaegerTrace] = []
|
||||
|
||||
|
||||
class _TracesQuery(BaseModel):
|
||||
service: str
|
||||
tags: str
|
||||
limit: int = 20
|
||||
lookback: str = "1h"
|
||||
|
||||
|
||||
def _settled(trace: JaegerTrace, names: set[str], prefixes: set[str]) -> bool:
|
||||
present = set(trace.span_names())
|
||||
return names.issubset(present) and all(
|
||||
any(name.startswith(prefix) for name in present) for prefix in prefixes
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OtelReader:
|
||||
query_url: str
|
||||
|
||||
def traces_for_call(self, call_id: str) -> list[JaegerTrace]:
|
||||
"""Every trace holding a span tagged with this call id. Jaeger matches
|
||||
spans server-side and returns their full traces; more than one hit for
|
||||
one call IS the split-trace bug, so this never collapses to one."""
|
||||
result = get(
|
||||
URL(f"{self.query_url}/api/traces"),
|
||||
headers=NoBody(),
|
||||
params=_TracesQuery(service=JAEGER_SERVICE, tags=json.dumps({CALL_ID_TAG: call_id})),
|
||||
response_type=JaegerTracesPage,
|
||||
timeout=30.0,
|
||||
)
|
||||
match result:
|
||||
case Success(data=page):
|
||||
return page.data
|
||||
case failure:
|
||||
pytest.fail(f"Jaeger query API at {self.query_url} failed: {failure}")
|
||||
|
||||
def poll_traces_for_call(
|
||||
self, *, call_id: str, settled_names: set[str], settled_prefixes: set[str]
|
||||
) -> list[JaegerTrace]:
|
||||
"""Poll until exactly one trace holds the call and it carries every span
|
||||
name in ``settled_names`` plus at least one name per prefix in
|
||||
``settled_prefixes`` (spans flush in batches, the cost write lands after
|
||||
the response), then return the hits. At the deadline the last hits are
|
||||
returned as-is so the caller's assertions report the real final state -
|
||||
on a split trace this never settles and the orphan comes back."""
|
||||
deadline = time.monotonic() + POLL_TIMEOUT
|
||||
hits: list[JaegerTrace] = []
|
||||
while time.monotonic() < deadline:
|
||||
hits = self.traces_for_call(call_id)
|
||||
if len(hits) == 1 and _settled(hits[0], settled_names, settled_prefixes):
|
||||
return hits
|
||||
time.sleep(POLL_INTERVAL)
|
||||
return hits
|
||||
|
||||
|
||||
def build_otel_reader() -> OtelReader:
|
||||
return OtelReader(query_url=OTEL_QUERY_URL)
|
||||
191
tests/e2e/logging/test_otel_trace_e2e.py
Normal file
191
tests/e2e/logging/test_otel_trace_e2e.py
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
"""Live e2e: OTEL trace completeness on the admin-owned destination (LIT-3787).
|
||||
|
||||
Covers logging.otel.success.exports_metric: a successful non-streaming call must
|
||||
land at the OTEL destination as ONE connected trace - a single root SERVER span
|
||||
with the auth phase, db lookups, and cost write under it, and the gen-AI CLIENT
|
||||
span parented into the same tree. The regression this pins: the proxy publishing
|
||||
the global TracerProvider before callbacks init made server spans export through
|
||||
a different provider than the preset's gen-AI spans, so the destination received
|
||||
the gen-AI span alone, dangling (fixed in #30590; verified failing at its parent
|
||||
commit 1bd603d1ac).
|
||||
|
||||
Both halves of the contract are asserted: the recorded state (the proxy reports
|
||||
the OTEL v2 logger active via /health/readiness/details) and the enforced
|
||||
behavior (the complete span tree at the destination, read back through the
|
||||
destination's own query API - never proxy-side "export succeeded" logs).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from e2e_config import CHEAP_ANTHROPIC_MODEL, unique_marker
|
||||
from e2e_http import NoBody, StreamingResponse, require_successful_call
|
||||
from lifecycle import ResourceManager
|
||||
from logging_client import LoggingClient
|
||||
from otel_client import JaegerTrace, OtelReader
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
MODEL = CHEAP_ANTHROPIC_MODEL
|
||||
COST_SPAN = "batch_write_to_db _PROXY_track_cost_callback"
|
||||
DB_SPAN_PREFIX = "postgres "
|
||||
#: The active OTEL v2 logger's name in /health/readiness/details success_callbacks.
|
||||
OTEL_V2_LOGGER_NAME = "OpenTelemetryV2"
|
||||
|
||||
|
||||
class _ReadinessDetails(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
success_callbacks: list[str] = []
|
||||
|
||||
|
||||
def _assert_otel_destination_configured(client: LoggingClient) -> None:
|
||||
"""Recorded state: the proxy reports the OTEL v2 logger among its active
|
||||
callbacks, so a missing/failed destination config fails here, before any
|
||||
traffic-based assertion can time out confusingly."""
|
||||
result = client.gateway.probe("/health/readiness/details", params=NoBody())
|
||||
assert result.status_code == 200, (
|
||||
f"/health/readiness/details must answer 200, got {result.status_code}: {result.body[:300]}"
|
||||
)
|
||||
details = _ReadinessDetails.model_validate_json(result.body)
|
||||
assert OTEL_V2_LOGGER_NAME in details.success_callbacks, (
|
||||
f"the proxy must report the {OTEL_V2_LOGGER_NAME} callback active "
|
||||
f"(LITELLM_OTEL_V2 + arize_phoenix preset in the compose config); got: {details.success_callbacks}"
|
||||
)
|
||||
|
||||
|
||||
def _first_ok(client: LoggingClient, send: Callable[[], StreamingResponse]) -> StreamingResponse:
|
||||
"""First successful call on a fresh key. A fresh key may briefly 401 until
|
||||
the data plane's auth cache picks it up, so retry on 401 to a deadline; a
|
||||
401 is rejected before the LLM call so it exports no gen-AI span and cannot
|
||||
contaminate the trace assertions. Any other failure is behavior under test
|
||||
and fails hard."""
|
||||
deadline = time.monotonic() + client.gateway.poll_timeout
|
||||
while True:
|
||||
outcome = send()
|
||||
if outcome.ok:
|
||||
return outcome
|
||||
if outcome.status_code != 401 or time.monotonic() >= deadline:
|
||||
require_successful_call(outcome)
|
||||
time.sleep(client.gateway.poll_interval)
|
||||
|
||||
|
||||
def _parent_ids(span_id: str, trace: JaegerTrace) -> list[str]:
|
||||
span = next(s for s in trace.spans if s.span_id == span_id)
|
||||
return [ref.span_id for ref in span.references if ref.ref_type == "CHILD_OF"]
|
||||
|
||||
|
||||
def _chain_reaches(span_id: str, root_id: str, trace: JaegerTrace) -> bool:
|
||||
"""Walk parent references (within the trace) from span_id up to root_id."""
|
||||
seen: set[str] = set()
|
||||
in_trace = {s.span_id for s in trace.spans}
|
||||
current = span_id
|
||||
while current not in seen:
|
||||
if current == root_id:
|
||||
return True
|
||||
seen.add(current)
|
||||
parents = [p for p in _parent_ids(current, trace) if p in in_trace]
|
||||
if not parents:
|
||||
return False
|
||||
current = parents[0]
|
||||
return False
|
||||
|
||||
|
||||
def _assert_complete_trace(hits: list[JaegerTrace], *, route: str, genai_span: str) -> None:
|
||||
"""The enforced behavior: the destination holds exactly one trace for the
|
||||
call, rooted at the SERVER span, with auth/db/cost children and the gen-AI
|
||||
span all connected into that one tree - no dangling parent references."""
|
||||
assert hits, (
|
||||
"no trace for this call arrived at the destination within the deadline "
|
||||
"(nothing tagged with its call id was found)"
|
||||
)
|
||||
assert len(hits) == 1, (
|
||||
f"expected exactly ONE trace for the call, got {len(hits)}: "
|
||||
f"{[(t.trace_id, t.span_names()) for t in hits]} - more than one trace for "
|
||||
"one call is the split-trace bug (gen-AI span exported away from its root)"
|
||||
)
|
||||
trace = hits[0]
|
||||
names = trace.span_names()
|
||||
in_trace = {span.span_id for span in trace.spans}
|
||||
|
||||
dangling = [
|
||||
span.operation_name
|
||||
for span in trace.spans
|
||||
if span.references and not any(ref.span_id in in_trace for ref in span.references)
|
||||
]
|
||||
assert not dangling, (
|
||||
f"span(s) {dangling} reference a parent that never reached the destination "
|
||||
f"(orphaned trace); spans present: {names}"
|
||||
)
|
||||
|
||||
roots = [span for span in trace.spans if not span.references]
|
||||
assert len(roots) == 1, f"expected exactly one root span, got {[s.operation_name for s in roots]}; spans: {names}"
|
||||
root = roots[0]
|
||||
assert root.operation_name == f"POST {route}", (
|
||||
f"the root must be the SERVER span 'POST {route}', got {root.operation_name!r}"
|
||||
)
|
||||
assert root.kind == "server", f"the root span must have kind=server, got {root.kind!r}"
|
||||
|
||||
assert f"auth {route}" in names, f"auth phase span 'auth {route}' missing; spans: {names}"
|
||||
assert any(name.startswith(DB_SPAN_PREFIX) for name in names), (
|
||||
f"no db ('{DB_SPAN_PREFIX}*') span in the trace; spans: {names}"
|
||||
)
|
||||
assert COST_SPAN in names, f"cost write span {COST_SPAN!r} missing; spans: {names}"
|
||||
|
||||
genai = next((span for span in trace.spans if span.operation_name == genai_span), None)
|
||||
assert genai is not None, f"gen-AI span {genai_span!r} missing; spans: {names}"
|
||||
assert genai.kind == "client", f"gen-AI span must have kind=client, got {genai.kind!r}"
|
||||
assert _chain_reaches(genai.span_id, root.span_id, trace), (
|
||||
f"gen-AI span {genai_span!r} is in the trace but its parent chain does not "
|
||||
f"reach the root SERVER span; spans: {names}"
|
||||
)
|
||||
|
||||
|
||||
def _settled_names(*, route: str, genai_span: str) -> set[str]:
|
||||
return {f"POST {route}", f"auth {route}", COST_SPAN, genai_span}
|
||||
|
||||
|
||||
class TestOtelTraceCompleteness:
|
||||
@pytest.mark.covers("logging.otel.success.exports_metric")
|
||||
def test_chat_completions_exports_complete_trace(
|
||||
self, client: LoggingClient, otel_reader: OtelReader, resources: ResourceManager
|
||||
) -> None:
|
||||
"""This test verifies that a successful non-streaming
|
||||
/chat/completions request produces one complete OTEL trace.
|
||||
|
||||
The trace should have a single server root span for the incoming request, with
|
||||
the authentication, database, and cost-recording work beneath it. The span for
|
||||
the actual model call must also belong to that same trace, rather than being
|
||||
exported separately with a missing parent.
|
||||
|
||||
This matters because a split trace is easy to miss: all of the spans may still
|
||||
arrive, but the model call appears without the surrounding request context.
|
||||
That makes it difficult to understand where time was spent, connect the model
|
||||
cost to the original request, or investigate a slow or failed call.
|
||||
|
||||
/chat/completions is the main OpenAI-compatible route used by most customers,
|
||||
so it is important that trace parenting works correctly on this path.
|
||||
"""
|
||||
route = "/chat/completions"
|
||||
_assert_otel_destination_configured(client)
|
||||
|
||||
key = client.key_with_alias(f"otel-trace-chat-{unique_marker()}", models=[MODEL])
|
||||
resources.defer(lambda: client.delete_key(key))
|
||||
|
||||
marker = unique_marker()
|
||||
outcome = _first_ok(
|
||||
client, lambda: client.chat_raw(key, MODEL, f"reply with one word {marker}", max_tokens=16)
|
||||
)
|
||||
assert outcome.call_id is not None, "success response must carry x-litellm-call-id"
|
||||
|
||||
hits = otel_reader.poll_traces_for_call(
|
||||
call_id=outcome.call_id,
|
||||
settled_names=_settled_names(route=route, genai_span=f"chat {MODEL}"),
|
||||
settled_prefixes={DB_SPAN_PREFIX},
|
||||
)
|
||||
_assert_complete_trace(hits, route=route, genai_span=f"chat {MODEL}")
|
||||
|
|
@ -2,7 +2,7 @@ import io
|
|||
import os
|
||||
import sys
|
||||
|
||||
from typing import Optional
|
||||
from typing import Optional, Union
|
||||
|
||||
sys.path.insert(0, os.path.abspath("../.."))
|
||||
|
||||
|
|
@ -12,6 +12,7 @@ import json
|
|||
import logging
|
||||
import time
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
|
@ -20,17 +21,24 @@ import litellm
|
|||
from litellm._logging import verbose_logger
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.responses.main import mock_responses_api_response
|
||||
from litellm.types.utils import StandardLoggingPayload
|
||||
from litellm.types.utils import (
|
||||
ModelResponse,
|
||||
ResponsesAPIResponse,
|
||||
StandardLoggingPayload,
|
||||
TextCompletionResponse,
|
||||
)
|
||||
|
||||
|
||||
class TestCustomLogger(CustomLogger):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.logged_standard_logging_payload: Optional[StandardLoggingPayload] = None
|
||||
self.response_obj: Optional[Union[ModelResponse, TextCompletionResponse, ResponsesAPIResponse]] = None
|
||||
|
||||
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
|
||||
standard_logging_payload = kwargs.get("standard_logging_object", None)
|
||||
self.logged_standard_logging_payload = standard_logging_payload
|
||||
self.response_obj = response_obj
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -108,6 +116,78 @@ async def test_dynamic_turn_off_message_logging_overrides_global_off(dynamic_tur
|
|||
assert standard_logging_payload["messages"][0]["content"] == expected_message_content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redaction_with_custom_logger_streaming():
|
||||
"""Test redaction of responses for custom logger callbacks"""
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
|
||||
class LoggingWithoutSyncSuccessHandler(Logging):
|
||||
def success_handler(self, result=None, start_time=None, end_time=None, cache_hit=None, **kwargs):
|
||||
pass
|
||||
|
||||
litellm.turn_off_message_logging = True
|
||||
test_custom_logger = TestCustomLogger()
|
||||
|
||||
try:
|
||||
litellm_logging_obj = LoggingWithoutSyncSuccessHandler(
|
||||
model="gpt-5-mini",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
stream=True,
|
||||
call_type="acompletion",
|
||||
litellm_call_id="1234",
|
||||
start_time=datetime.now(),
|
||||
function_id="1234",
|
||||
dynamic_async_success_callbacks=[test_custom_logger],
|
||||
)
|
||||
|
||||
response = await litellm.acompletion(
|
||||
model="gpt-5-mini",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
mock_response="hello",
|
||||
stream=True,
|
||||
litellm_logging_obj=litellm_logging_obj,
|
||||
)
|
||||
|
||||
# Consume the stream to trigger logging
|
||||
chunks = []
|
||||
async for chunk in response:
|
||||
chunks.append(chunk)
|
||||
|
||||
await asyncio.sleep(1)
|
||||
async_complete_streaming_response = test_custom_logger.response_obj
|
||||
assert async_complete_streaming_response is not None
|
||||
assert async_complete_streaming_response.choices[0].message.content == "redacted-by-litellm"
|
||||
finally:
|
||||
litellm.turn_off_message_logging = False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streaming_redaction_scoped_to_opted_out_logger():
|
||||
"""One logger opting out of message logging must not blank the response for other loggers"""
|
||||
litellm.turn_off_message_logging = False
|
||||
opted_out_logger = TestCustomLogger(message_logging=False)
|
||||
compliant_logger = TestCustomLogger()
|
||||
litellm.callbacks = [opted_out_logger, compliant_logger]
|
||||
|
||||
try:
|
||||
response = await litellm.acompletion(
|
||||
model="gpt-5-mini",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
mock_response="hello",
|
||||
stream=True,
|
||||
)
|
||||
async for _ in response:
|
||||
pass
|
||||
|
||||
await asyncio.sleep(1)
|
||||
assert opted_out_logger.response_obj is not None
|
||||
assert opted_out_logger.response_obj.choices[0].message.content == "redacted-by-litellm"
|
||||
assert compliant_logger.response_obj is not None
|
||||
assert compliant_logger.response_obj.choices[0].message.content == "hello"
|
||||
finally:
|
||||
litellm.callbacks = []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redaction_responses_api():
|
||||
"""Test redaction with ResponsesAPIResponse format"""
|
||||
|
|
|
|||
|
|
@ -0,0 +1,180 @@
|
|||
"""
|
||||
Unit tests for the video-seconds and images-generated Prometheus counters (LIT-4254).
|
||||
|
||||
Video providers report ``duration_seconds`` inside the usage object that lands
|
||||
on ``standard_logging_payload["metadata"]["usage_object"]``; image generation
|
||||
calls report ``output_image_count`` there. Both counters are sparse: only
|
||||
incremented when the value is present and > 0.
|
||||
"""
|
||||
|
||||
from typing import get_args
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.integrations.prometheus import PrometheusLogger
|
||||
from litellm.types.integrations.prometheus import (
|
||||
DEFINED_PROMETHEUS_METRICS,
|
||||
PrometheusMetricLabels,
|
||||
UserAPIKeyLabelValues,
|
||||
)
|
||||
|
||||
MEDIA_GENERATION_METRICS = [
|
||||
"litellm_video_duration_seconds_metric",
|
||||
"litellm_images_generated_metric",
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_enum_values():
|
||||
return UserAPIKeyLabelValues(
|
||||
end_user="test-end-user",
|
||||
hashed_api_key="test-key-hash",
|
||||
api_key_alias="test-key-alias",
|
||||
team="test-team",
|
||||
team_alias="test-team-alias",
|
||||
user="test-user",
|
||||
model="sora-2",
|
||||
)
|
||||
|
||||
|
||||
def _make_mock_logger():
|
||||
logger = MagicMock()
|
||||
for name in MEDIA_GENERATION_METRICS:
|
||||
setattr(logger, name, MagicMock())
|
||||
logger.get_labels_for_metric = MagicMock(
|
||||
return_value=[
|
||||
"model",
|
||||
"hashed_api_key",
|
||||
"api_key_alias",
|
||||
"team",
|
||||
"team_alias",
|
||||
"end_user",
|
||||
"user",
|
||||
]
|
||||
)
|
||||
return logger
|
||||
|
||||
|
||||
class TestMediaGenerationMetricsRegistration:
|
||||
def test_metrics_in_defined_prometheus_metrics(self):
|
||||
defined = get_args(DEFINED_PROMETHEUS_METRICS)
|
||||
for name in MEDIA_GENERATION_METRICS:
|
||||
assert name in defined, f"{name} missing from DEFINED_PROMETHEUS_METRICS"
|
||||
|
||||
def test_metric_labels_defined(self):
|
||||
for name in MEDIA_GENERATION_METRICS:
|
||||
assert hasattr(PrometheusMetricLabels, name), f"{name} missing from PrometheusMetricLabels"
|
||||
|
||||
def test_metrics_share_output_token_label_set(self):
|
||||
assert (
|
||||
PrometheusMetricLabels.litellm_video_duration_seconds_metric
|
||||
== PrometheusMetricLabels.litellm_output_tokens_metric
|
||||
)
|
||||
assert (
|
||||
PrometheusMetricLabels.litellm_images_generated_metric
|
||||
== PrometheusMetricLabels.litellm_output_tokens_metric
|
||||
)
|
||||
|
||||
def test_runtime_label_set_matches_output_tokens_metric(self):
|
||||
"""Full parity with litellm_output_tokens_metric, including the org labels
|
||||
appended via _org_label_metrics, so existing token dashboards can be cloned."""
|
||||
expected = PrometheusMetricLabels.get_labels("litellm_output_tokens_metric")
|
||||
for name in MEDIA_GENERATION_METRICS:
|
||||
assert PrometheusMetricLabels.get_labels(name) == expected
|
||||
|
||||
|
||||
class TestIncrementMediaGenerationMetrics:
|
||||
def test_video_duration_incremented(self, sample_enum_values):
|
||||
logger = _make_mock_logger()
|
||||
payload = {"metadata": {"usage_object": {"duration_seconds": 8.0}}}
|
||||
|
||||
PrometheusLogger._increment_media_generation_metrics(
|
||||
logger,
|
||||
standard_logging_payload=payload,
|
||||
enum_values=sample_enum_values,
|
||||
)
|
||||
|
||||
logger.litellm_video_duration_seconds_metric.labels().inc.assert_called_once_with(8.0)
|
||||
logger.litellm_images_generated_metric.labels.assert_not_called()
|
||||
|
||||
def test_image_count_incremented(self, sample_enum_values):
|
||||
logger = _make_mock_logger()
|
||||
payload = {
|
||||
"metadata": {
|
||||
"usage_object": {
|
||||
"prompt_tokens": 18,
|
||||
"completion_tokens": 391,
|
||||
"total_tokens": 409,
|
||||
"output_image_count": 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PrometheusLogger._increment_media_generation_metrics(
|
||||
logger,
|
||||
standard_logging_payload=payload,
|
||||
enum_values=sample_enum_values,
|
||||
)
|
||||
|
||||
logger.litellm_images_generated_metric.labels().inc.assert_called_once_with(2.0)
|
||||
logger.litellm_video_duration_seconds_metric.labels.assert_not_called()
|
||||
|
||||
def test_token_only_usage_is_a_noop(self, sample_enum_values):
|
||||
logger = _make_mock_logger()
|
||||
payload = {
|
||||
"metadata": {
|
||||
"usage_object": {
|
||||
"prompt_tokens": 10,
|
||||
"completion_tokens": 20,
|
||||
"total_tokens": 30,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PrometheusLogger._increment_media_generation_metrics(
|
||||
logger,
|
||||
standard_logging_payload=payload,
|
||||
enum_values=sample_enum_values,
|
||||
)
|
||||
|
||||
for name in MEDIA_GENERATION_METRICS:
|
||||
getattr(logger, name).labels.assert_not_called()
|
||||
|
||||
@pytest.mark.parametrize("bad_value", [0, 0.0, None, -4.0, "4", True])
|
||||
def test_non_positive_or_non_numeric_values_are_ignored(self, sample_enum_values, bad_value):
|
||||
logger = _make_mock_logger()
|
||||
payload = {
|
||||
"metadata": {
|
||||
"usage_object": {
|
||||
"duration_seconds": bad_value,
|
||||
"output_image_count": bad_value,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PrometheusLogger._increment_media_generation_metrics(
|
||||
logger,
|
||||
standard_logging_payload=payload,
|
||||
enum_values=sample_enum_values,
|
||||
)
|
||||
|
||||
for name in MEDIA_GENERATION_METRICS:
|
||||
getattr(logger, name).labels.assert_not_called()
|
||||
|
||||
def test_missing_usage_object_is_a_noop(self, sample_enum_values):
|
||||
logger = _make_mock_logger()
|
||||
|
||||
for payload in ({"metadata": {}}, {"metadata": None}, {"metadata": {"usage_object": "redacted"}}):
|
||||
PrometheusLogger._increment_media_generation_metrics(
|
||||
logger,
|
||||
standard_logging_payload=payload,
|
||||
enum_values=sample_enum_values,
|
||||
)
|
||||
|
||||
for name in MEDIA_GENERATION_METRICS:
|
||||
getattr(logger, name).labels.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
|
|
@ -326,3 +326,148 @@ async def test_should_leave_rate_limit_labels_blank_for_non_rate_limit_failure()
|
|||
assert isinstance(enum_values, UserAPIKeyLabelValues)
|
||||
assert enum_values.rate_limit_category is None
|
||||
assert enum_values.rate_limit_type is None
|
||||
|
||||
|
||||
def _logger_with_mock_virtual_key_gauges() -> PrometheusLogger:
|
||||
with patch(
|
||||
"litellm.integrations.prometheus.PrometheusLogger.__init__", return_value=None
|
||||
):
|
||||
logger = PrometheusLogger()
|
||||
logger.litellm_remaining_api_key_requests_for_model = MagicMock()
|
||||
logger.litellm_remaining_api_key_tokens_for_model = MagicMock()
|
||||
logger.get_labels_for_metric = MagicMock(return_value=[])
|
||||
return logger
|
||||
|
||||
|
||||
def _kwargs_with_v3_rate_limit_headers(additional_headers: dict) -> dict:
|
||||
return {
|
||||
"litellm_params": {"metadata": {"model_group": "gpt-4o-mini"}},
|
||||
"standard_logging_object": {
|
||||
"metadata": {},
|
||||
"hidden_params": {"additional_headers": additional_headers},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _set_virtual_key_metrics(logger: PrometheusLogger, kwargs: dict) -> None:
|
||||
logger._set_virtual_key_rate_limit_metrics(
|
||||
user_api_key="test-hash",
|
||||
user_api_key_alias="test-alias",
|
||||
kwargs=kwargs,
|
||||
metadata=kwargs["litellm_params"]["metadata"],
|
||||
model_id="model-123",
|
||||
)
|
||||
|
||||
|
||||
def test_should_read_v3_remaining_headers_when_metadata_keys_absent():
|
||||
"""
|
||||
Regression for LIT-2577: the default v3 rate limiter writes remaining
|
||||
per-(key, model) values into
|
||||
``standard_logging_object.hidden_params.additional_headers`` as
|
||||
``x-ratelimit-model_per_key-remaining-{requests,tokens}`` and never sets
|
||||
the legacy ``litellm-key-remaining-*`` metadata keys, so the gauges were
|
||||
pinned to ``sys.maxsize``.
|
||||
"""
|
||||
logger = _logger_with_mock_virtual_key_gauges()
|
||||
kwargs = _kwargs_with_v3_rate_limit_headers(
|
||||
{
|
||||
"x-ratelimit-model_per_key-remaining-requests": 42,
|
||||
"x-ratelimit-model_per_key-remaining-tokens": 900,
|
||||
"x-ratelimit-model_per_key-limit-requests": 100,
|
||||
"x-ratelimit-model_per_key-limit-tokens": 1000,
|
||||
}
|
||||
)
|
||||
|
||||
_set_virtual_key_metrics(logger, kwargs)
|
||||
|
||||
logger.litellm_remaining_api_key_requests_for_model.labels.return_value.set.assert_called_once_with(
|
||||
42
|
||||
)
|
||||
logger.litellm_remaining_api_key_tokens_for_model.labels.return_value.set.assert_called_once_with(
|
||||
900
|
||||
)
|
||||
|
||||
|
||||
def test_should_prefer_legacy_metadata_keys_over_v3_headers():
|
||||
logger = _logger_with_mock_virtual_key_gauges()
|
||||
kwargs = _kwargs_with_v3_rate_limit_headers(
|
||||
{
|
||||
"x-ratelimit-model_per_key-remaining-requests": 42,
|
||||
"x-ratelimit-model_per_key-remaining-tokens": 900,
|
||||
}
|
||||
)
|
||||
kwargs["litellm_params"]["metadata"].update(
|
||||
{
|
||||
"litellm-key-remaining-requests-gpt-4o-mini": 3,
|
||||
"litellm-key-remaining-tokens-gpt-4o-mini": 200,
|
||||
}
|
||||
)
|
||||
|
||||
_set_virtual_key_metrics(logger, kwargs)
|
||||
|
||||
logger.litellm_remaining_api_key_requests_for_model.labels.return_value.set.assert_called_once_with(
|
||||
3
|
||||
)
|
||||
logger.litellm_remaining_api_key_tokens_for_model.labels.return_value.set.assert_called_once_with(
|
||||
200
|
||||
)
|
||||
|
||||
|
||||
def test_should_treat_zero_v3_remaining_as_zero():
|
||||
logger = _logger_with_mock_virtual_key_gauges()
|
||||
kwargs = _kwargs_with_v3_rate_limit_headers(
|
||||
{
|
||||
"x-ratelimit-model_per_key-remaining-requests": 0,
|
||||
"x-ratelimit-model_per_key-remaining-tokens": 0,
|
||||
}
|
||||
)
|
||||
|
||||
_set_virtual_key_metrics(logger, kwargs)
|
||||
|
||||
logger.litellm_remaining_api_key_requests_for_model.labels.return_value.set.assert_called_once_with(
|
||||
0
|
||||
)
|
||||
logger.litellm_remaining_api_key_tokens_for_model.labels.return_value.set.assert_called_once_with(
|
||||
0
|
||||
)
|
||||
|
||||
|
||||
def test_should_keep_maxsize_sentinel_when_no_rate_limit_source_present():
|
||||
import sys
|
||||
|
||||
logger = _logger_with_mock_virtual_key_gauges()
|
||||
kwargs = {
|
||||
"litellm_params": {"metadata": {"model_group": "gpt-4o-mini"}},
|
||||
"standard_logging_object": {"metadata": {}, "hidden_params": {}},
|
||||
}
|
||||
|
||||
_set_virtual_key_metrics(logger, kwargs)
|
||||
|
||||
logger.litellm_remaining_api_key_requests_for_model.labels.return_value.set.assert_called_once_with(
|
||||
sys.maxsize
|
||||
)
|
||||
logger.litellm_remaining_api_key_tokens_for_model.labels.return_value.set.assert_called_once_with(
|
||||
sys.maxsize
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_value", ["not-a-number", None, True])
|
||||
def test_should_ignore_non_int_v3_header_values(bad_value):
|
||||
import sys
|
||||
|
||||
logger = _logger_with_mock_virtual_key_gauges()
|
||||
kwargs = _kwargs_with_v3_rate_limit_headers(
|
||||
{
|
||||
"x-ratelimit-model_per_key-remaining-requests": bad_value,
|
||||
"x-ratelimit-model_per_key-remaining-tokens": bad_value,
|
||||
}
|
||||
)
|
||||
|
||||
_set_virtual_key_metrics(logger, kwargs)
|
||||
|
||||
logger.litellm_remaining_api_key_requests_for_model.labels.return_value.set.assert_called_once_with(
|
||||
sys.maxsize
|
||||
)
|
||||
logger.litellm_remaining_api_key_tokens_for_model.labels.return_value.set.assert_called_once_with(
|
||||
sys.maxsize
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3707,3 +3707,69 @@ def test_set_cost_breakdown_stores_reasoning_cost():
|
|||
cost_for_built_in_tools_cost_usd_dollar=0.0,
|
||||
)
|
||||
assert "reasoning_cost" not in no_reasoning.cost_breakdown
|
||||
|
||||
|
||||
def _build_payload_for_media_response(logging_obj, init_response_obj, kwargs=None):
|
||||
import datetime
|
||||
|
||||
from litellm.litellm_core_utils.litellm_logging import (
|
||||
get_standard_logging_object_payload,
|
||||
)
|
||||
|
||||
now = datetime.datetime.now()
|
||||
return get_standard_logging_object_payload(
|
||||
kwargs=kwargs or {"litellm_call_id": "media-call-id", "model": "test-model", "messages": []},
|
||||
init_response_obj=init_response_obj,
|
||||
start_time=now,
|
||||
end_time=now,
|
||||
logging_obj=logging_obj,
|
||||
status="success",
|
||||
)
|
||||
|
||||
|
||||
def test_image_response_sets_output_image_count_on_usage_object(logging_obj):
|
||||
"""Generated-image count must land on metadata.usage_object for callbacks (e.g. Prometheus)."""
|
||||
from litellm.types.utils import ImageResponse
|
||||
|
||||
response = ImageResponse(created=1, data=[{"url": "https://img/1"}, {"url": "https://img/2"}])
|
||||
|
||||
payload = _build_payload_for_media_response(logging_obj, response)
|
||||
|
||||
assert payload is not None
|
||||
assert payload["metadata"]["usage_object"]["output_image_count"] == 2
|
||||
|
||||
|
||||
def test_output_image_count_survives_message_redaction(logging_obj, monkeypatch):
|
||||
"""Redaction replaces the ImageResponse body, so the count must be captured pre-redaction."""
|
||||
import litellm
|
||||
from litellm.types.utils import ImageResponse
|
||||
|
||||
monkeypatch.setattr(litellm, "turn_off_message_logging", True)
|
||||
response = ImageResponse(created=1, data=[{"url": "https://img/1"}])
|
||||
|
||||
payload = _build_payload_for_media_response(logging_obj, response)
|
||||
|
||||
assert payload is not None
|
||||
assert payload["response"] == {"text": "redacted-by-litellm"}
|
||||
assert payload["metadata"]["usage_object"]["output_image_count"] == 1
|
||||
|
||||
|
||||
def test_non_image_response_has_no_output_image_count(logging_obj):
|
||||
payload = _build_payload_for_media_response(
|
||||
logging_obj, {"id": "chatcmpl-1", "usage": {"prompt_tokens": 1, "completion_tokens": 2}}
|
||||
)
|
||||
|
||||
assert payload is not None
|
||||
assert "output_image_count" not in payload["metadata"]["usage_object"]
|
||||
|
||||
|
||||
def test_zero_token_video_usage_preserves_duration_seconds(logging_obj):
|
||||
"""Video usage bills by duration; the payload must keep duration_seconds even with zero tokens."""
|
||||
payload = _build_payload_for_media_response(
|
||||
logging_obj, {"id": "video-1", "usage": {"duration_seconds": 4.0}}
|
||||
)
|
||||
|
||||
assert payload is not None
|
||||
assert payload["metadata"]["usage_object"]["duration_seconds"] == 4.0
|
||||
assert payload["total_tokens"] == 0
|
||||
assert payload["completion_tokens"] == 0
|
||||
|
|
|
|||
|
|
@ -10,9 +10,11 @@ from types import SimpleNamespace
|
|||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.litellm_core_utils.redact_messages import (
|
||||
_redact_responses_api_output,
|
||||
perform_redaction,
|
||||
redact_streaming_responses_for_custom_logger,
|
||||
should_redact_message_logging,
|
||||
)
|
||||
from litellm.responses.main import mock_responses_api_response
|
||||
|
|
@ -442,3 +444,109 @@ class TestPerformRedaction:
|
|||
assert "vertex_ai_url_context_metadata" not in hidden_params
|
||||
assert "vertex_ai_safety_ratings" not in hidden_params
|
||||
assert "vertex_ai_citation_metadata" not in hidden_params
|
||||
|
||||
def test_redact_async_complete_streaming_response(self):
|
||||
"""Test that async_complete_streaming_response is properly redacted."""
|
||||
response_obj = litellm.ModelResponse(
|
||||
choices=[
|
||||
litellm.Choices(
|
||||
message=litellm.Message(content="secret content", role="assistant")
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
model_call_details = {
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"prompt": "hi",
|
||||
"input": "hi",
|
||||
"stream": True,
|
||||
"async_complete_streaming_response": response_obj,
|
||||
}
|
||||
|
||||
perform_redaction(model_call_details, result=None)
|
||||
|
||||
redacted_response = model_call_details["async_complete_streaming_response"]
|
||||
assert redacted_response.choices[0].message.content == "redacted-by-litellm"
|
||||
|
||||
def test_redact_complete_streaming_response(self):
|
||||
"""Test that complete_streaming_response is properly redacted."""
|
||||
response_obj = litellm.ModelResponse(
|
||||
choices=[
|
||||
litellm.Choices(
|
||||
message=litellm.Message(content="secret content", role="assistant")
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
model_call_details = {
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"prompt": "hi",
|
||||
"input": "hi",
|
||||
"stream": True,
|
||||
"complete_streaming_response": response_obj,
|
||||
}
|
||||
|
||||
perform_redaction(model_call_details, result=None)
|
||||
|
||||
redacted_response = model_call_details["complete_streaming_response"]
|
||||
assert redacted_response.choices[0].message.content == "redacted-by-litellm"
|
||||
|
||||
def test_streaming_responses_untouched_when_disabled(self):
|
||||
response_obj = litellm.ModelResponse(
|
||||
choices=[
|
||||
litellm.Choices(
|
||||
message=litellm.Message(content="secret content", role="assistant")
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
model_call_details = {
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"prompt": "hi",
|
||||
"input": "hi",
|
||||
"stream": True,
|
||||
"async_complete_streaming_response": response_obj,
|
||||
}
|
||||
|
||||
perform_redaction(model_call_details, result=None, redact_streaming_responses=False)
|
||||
|
||||
assert response_obj.choices[0].message.content == "secret content"
|
||||
|
||||
|
||||
class TestRedactStreamingResponsesForCustomLogger:
|
||||
def _model_call_details(self):
|
||||
response_obj = litellm.ModelResponse(
|
||||
choices=[
|
||||
litellm.Choices(
|
||||
message=litellm.Message(content="secret content", role="assistant")
|
||||
)
|
||||
]
|
||||
)
|
||||
return {
|
||||
"stream": True,
|
||||
"async_complete_streaming_response": response_obj,
|
||||
}, response_obj
|
||||
|
||||
def test_opted_out_logger_gets_redacted_copy(self):
|
||||
model_call_details, response_obj = self._model_call_details()
|
||||
opted_out_logger = CustomLogger(message_logging=False)
|
||||
|
||||
redacted_details = redact_streaming_responses_for_custom_logger(
|
||||
model_call_details=model_call_details, custom_logger=opted_out_logger
|
||||
)
|
||||
|
||||
redacted_response = redacted_details["async_complete_streaming_response"]
|
||||
assert redacted_response.choices[0].message.content == "redacted-by-litellm"
|
||||
assert response_obj.choices[0].message.content == "secret content"
|
||||
assert model_call_details["async_complete_streaming_response"] is response_obj
|
||||
|
||||
def test_compliant_logger_gets_shared_response(self):
|
||||
model_call_details, response_obj = self._model_call_details()
|
||||
compliant_logger = CustomLogger()
|
||||
|
||||
result_details = redact_streaming_responses_for_custom_logger(
|
||||
model_call_details=model_call_details, custom_logger=compliant_logger
|
||||
)
|
||||
|
||||
assert result_details is model_call_details
|
||||
assert response_obj.choices[0].message.content == "secret content"
|
||||
|
|
|
|||
|
|
@ -99,17 +99,11 @@ class TestContextManagementConversion:
|
|||
}
|
||||
)
|
||||
kwargs = _ADAPTER.translate_request(req)
|
||||
assert kwargs["context_management"] == [
|
||||
{"type": "compaction", "compact_threshold": 100000}
|
||||
]
|
||||
assert kwargs["context_management"] == [{"type": "compaction", "compact_threshold": 100000}]
|
||||
|
||||
def test_translate_request_drops_anthropic_only_context_management(self):
|
||||
"""context_management with only unknown edit types is omitted from kwargs."""
|
||||
req = _make_request(
|
||||
context_management={
|
||||
"edits": [{"type": "clear_thinking_20251015", "keep": "all"}]
|
||||
}
|
||||
)
|
||||
req = _make_request(context_management={"edits": [{"type": "clear_thinking_20251015", "keep": "all"}]})
|
||||
kwargs = _ADAPTER.translate_request(req)
|
||||
assert "context_management" not in kwargs
|
||||
|
||||
|
|
@ -134,9 +128,7 @@ class TestOutputConfigStructuredOutput:
|
|||
|
||||
def test_output_config_format_json_schema_converted(self):
|
||||
"""output_config.format.json_schema is converted to OpenAI text.format."""
|
||||
req = _make_request(
|
||||
output_config={"format": {"type": "json_schema", "schema": self._SCHEMA}}
|
||||
)
|
||||
req = _make_request(output_config={"format": {"type": "json_schema", "schema": self._SCHEMA}})
|
||||
kwargs = _ADAPTER.translate_request(req)
|
||||
assert "text" in kwargs
|
||||
fmt = kwargs["text"]["format"]
|
||||
|
|
@ -153,9 +145,7 @@ class TestOutputConfigStructuredOutput:
|
|||
|
||||
def test_output_format_still_works(self):
|
||||
"""The original output_format field still takes precedence when present."""
|
||||
req = _make_request(
|
||||
output_format={"type": "json_schema", "schema": self._SCHEMA}
|
||||
)
|
||||
req = _make_request(output_format={"type": "json_schema", "schema": self._SCHEMA})
|
||||
kwargs = _ADAPTER.translate_request(req)
|
||||
assert "text" in kwargs
|
||||
assert kwargs["text"]["format"]["type"] == "json_schema"
|
||||
|
|
@ -250,9 +240,7 @@ class TestTranslateMessagesToResponsesInput:
|
|||
]
|
||||
result = _translate_messages(messages)
|
||||
assert len(result) == 1
|
||||
assert result[0]["content"] == [
|
||||
{"type": "input_image", "image_url": "data:image/png;base64,abc123"}
|
||||
]
|
||||
assert result[0]["content"] == [{"type": "input_image", "image_url": "data:image/png;base64,abc123"}]
|
||||
|
||||
def test_user_url_image(self):
|
||||
"""User message with URL image source becomes input_image with the URL."""
|
||||
|
|
@ -268,9 +256,7 @@ class TestTranslateMessagesToResponsesInput:
|
|||
}
|
||||
]
|
||||
result = _translate_messages(messages)
|
||||
assert result[0]["content"] == [
|
||||
{"type": "input_image", "image_url": "https://example.com/img.jpg"}
|
||||
]
|
||||
assert result[0]["content"] == [{"type": "input_image", "image_url": "https://example.com/img.jpg"}]
|
||||
|
||||
def test_user_base64_image_empty_data_skipped(self):
|
||||
"""Base64 image with empty data is skipped (no URL can be formed)."""
|
||||
|
|
@ -341,9 +327,7 @@ class TestTranslateMessagesToResponsesInput:
|
|||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "tool_result", "tool_use_id": "call_null", "content": None}
|
||||
],
|
||||
"content": [{"type": "tool_result", "tool_use_id": "call_null", "content": None}],
|
||||
}
|
||||
]
|
||||
result = _translate_messages(messages)
|
||||
|
|
@ -370,9 +354,7 @@ class TestTranslateMessagesToResponsesInput:
|
|||
}
|
||||
]
|
||||
result = _translate_messages(messages)
|
||||
assert result[0]["content"] == [
|
||||
{"type": "output_text", "text": "Here is the answer."}
|
||||
]
|
||||
assert result[0]["content"] == [{"type": "output_text", "text": "Here is the answer."}]
|
||||
|
||||
def test_assistant_tool_use_becomes_function_call(self):
|
||||
"""Assistant tool_use block becomes a top-level function_call item."""
|
||||
|
|
@ -404,15 +386,11 @@ class TestTranslateMessagesToResponsesInput:
|
|||
messages = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "Let me reason step by step."}
|
||||
],
|
||||
"content": [{"type": "thinking", "thinking": "Let me reason step by step."}],
|
||||
}
|
||||
]
|
||||
result = _translate_messages(messages)
|
||||
assert result[0]["content"] == [
|
||||
{"type": "output_text", "text": "Let me reason step by step."}
|
||||
]
|
||||
assert result[0]["content"] == [{"type": "output_text", "text": "Let me reason step by step."}]
|
||||
|
||||
def test_assistant_empty_thinking_block_skipped(self):
|
||||
"""Assistant thinking block with empty thinking text is skipped."""
|
||||
|
|
@ -584,28 +562,27 @@ class TestTranslateToolsToResponsesAPI:
|
|||
|
||||
|
||||
class TestTranslateToolChoiceToResponsesAPI:
|
||||
"""Anthropic tool_choice -> Responses API tool_choice."""
|
||||
"""Anthropic tool_choice -> Responses API tool_choice.
|
||||
|
||||
def test_auto_maps_to_auto(self):
|
||||
assert _ADAPTER.translate_tool_choice_to_responses_api({"type": "auto"}) == {
|
||||
"type": "auto"
|
||||
}
|
||||
The Responses API's tool_choice schema (openai.types.responses.tool_choice_options)
|
||||
is a bare Literal["none", "auto", "required"] for these simple cases - not an
|
||||
object like {"type": "auto"}. Sending the object shape to an OpenAI-compatible
|
||||
server gets rejected with a pydantic validation error.
|
||||
"""
|
||||
|
||||
def test_any_maps_to_required(self):
|
||||
assert _ADAPTER.translate_tool_choice_to_responses_api({"type": "any"}) == {
|
||||
"type": "required"
|
||||
}
|
||||
def test_auto_maps_to_bare_string_auto(self):
|
||||
assert _ADAPTER.translate_tool_choice_to_responses_api({"type": "auto"}) == "auto"
|
||||
|
||||
def test_any_maps_to_bare_string_required(self):
|
||||
assert _ADAPTER.translate_tool_choice_to_responses_api({"type": "any"}) == "required"
|
||||
|
||||
def test_none_maps_to_bare_string_none(self):
|
||||
assert _ADAPTER.translate_tool_choice_to_responses_api({"type": "none"}) == "none"
|
||||
|
||||
def test_specific_tool_maps_to_function(self):
|
||||
result = _ADAPTER.translate_tool_choice_to_responses_api(
|
||||
{"type": "tool", "name": "get_weather"}
|
||||
)
|
||||
result = _ADAPTER.translate_tool_choice_to_responses_api({"type": "tool", "name": "get_weather"})
|
||||
assert result == {"type": "function", "name": "get_weather"}
|
||||
|
||||
def test_unknown_type_defaults_to_auto(self):
|
||||
result = _ADAPTER.translate_tool_choice_to_responses_api({"type": "none"})
|
||||
assert result == {"type": "auto"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# translate_thinking_to_reasoning
|
||||
|
|
@ -616,17 +593,13 @@ class TestTranslateThinkingToReasoning:
|
|||
"""Anthropic thinking param -> Responses API reasoning param."""
|
||||
|
||||
def test_budget_high_effort(self):
|
||||
result = _ADAPTER.translate_thinking_to_reasoning(
|
||||
{"type": "enabled", "budget_tokens": 10000}
|
||||
)
|
||||
result = _ADAPTER.translate_thinking_to_reasoning({"type": "enabled", "budget_tokens": 10000})
|
||||
# Default (reasoning_auto_summary=False): only effort, no summary
|
||||
assert result == {"effort": "high"}
|
||||
assert result is not None and "summary" not in result
|
||||
|
||||
def test_budget_above_threshold_high_effort(self):
|
||||
result = _ADAPTER.translate_thinking_to_reasoning(
|
||||
{"type": "enabled", "budget_tokens": 50000}
|
||||
)
|
||||
result = _ADAPTER.translate_thinking_to_reasoning({"type": "enabled", "budget_tokens": 50000})
|
||||
assert result is not None
|
||||
assert result["effort"] == "high"
|
||||
assert "summary" not in result
|
||||
|
|
@ -652,9 +625,7 @@ class TestTranslateThinkingToReasoning:
|
|||
assert result is not None and "summary" not in result
|
||||
|
||||
def test_budget_minimal_effort(self):
|
||||
result = _ADAPTER.translate_thinking_to_reasoning(
|
||||
{"type": "enabled", "budget_tokens": 500}
|
||||
)
|
||||
result = _ADAPTER.translate_thinking_to_reasoning({"type": "enabled", "budget_tokens": 500})
|
||||
assert result == {"effort": "minimal"}
|
||||
assert result is not None and "summary" not in result
|
||||
|
||||
|
|
@ -707,9 +678,7 @@ class TestTranslateThinkingToReasoning:
|
|||
original = litellm.reasoning_auto_summary
|
||||
try:
|
||||
litellm.reasoning_auto_summary = True
|
||||
result = _ADAPTER.translate_thinking_to_reasoning(
|
||||
{"type": "enabled", "budget_tokens": 10000}
|
||||
)
|
||||
result = _ADAPTER.translate_thinking_to_reasoning({"type": "enabled", "budget_tokens": 10000})
|
||||
assert result == {"effort": "high", "summary": "detailed"}
|
||||
finally:
|
||||
litellm.reasoning_auto_summary = original
|
||||
|
|
@ -789,11 +758,7 @@ class TestTranslateRequestBroaderCoverage:
|
|||
assert kwargs["top_p"] == 0.9
|
||||
|
||||
def test_tools_translated(self):
|
||||
req = _make_request(
|
||||
tools=[
|
||||
{"name": "calculator", "description": "Does math.", "input_schema": {}}
|
||||
]
|
||||
)
|
||||
req = _make_request(tools=[{"name": "calculator", "description": "Does math.", "input_schema": {}}])
|
||||
kwargs = _ADAPTER.translate_request(req)
|
||||
assert len(kwargs["tools"]) == 1
|
||||
assert kwargs["tools"][0]["name"] == "calculator"
|
||||
|
|
@ -929,9 +894,7 @@ class TestTranslateResponse:
|
|||
|
||||
def test_multiple_text_parts(self):
|
||||
"""Multiple output_text parts become multiple text content blocks."""
|
||||
response = _make_mock_response(
|
||||
output=[_make_output_message(["Part 1", "Part 2"])]
|
||||
)
|
||||
response = _make_mock_response(output=[_make_output_message(["Part 1", "Part 2"])])
|
||||
result: Any = _ADAPTER.translate_response(response)
|
||||
assert len(result["content"]) == 2
|
||||
assert result["content"][0]["text"] == "Part 1"
|
||||
|
|
|
|||
|
|
@ -158,6 +158,54 @@ class TestClaudePlatformActionsCovered:
|
|||
)
|
||||
|
||||
|
||||
class TestBedrockMantleActionsCovered:
|
||||
"""LIT-3859: bedrock_mantle inference authorizes against the
|
||||
``bedrock-mantle`` action namespace, so the session-policy ceiling
|
||||
must include it or every Mantle request via OIDC/WIF auth denies
|
||||
with "no session policy allows the bedrock-mantle:CreateInference
|
||||
action" even when the role's identity policy grants it."""
|
||||
|
||||
def test_bedrock_mantle_create_inference_present(self):
|
||||
policy = _captured_policy()
|
||||
all_actions: set = set()
|
||||
for stmt in policy["Statement"]:
|
||||
stmt_actions = stmt.get("Action")
|
||||
if isinstance(stmt_actions, str):
|
||||
all_actions.add(stmt_actions)
|
||||
elif isinstance(stmt_actions, list):
|
||||
all_actions.update(stmt_actions)
|
||||
assert "bedrock-mantle:CreateInference" in all_actions, (
|
||||
"bedrock-mantle:CreateInference missing from session policy — "
|
||||
"bedrock_mantle/* requests will 403 on OIDC/WIF auth"
|
||||
)
|
||||
|
||||
def test_bedrock_mantle_statement_allows(self):
|
||||
policy = _captured_policy()
|
||||
stmt = _statement_by_sid(policy, "BedrockMantleLiteLLM")
|
||||
assert stmt["Effect"] == "Allow"
|
||||
assert stmt["Resource"] == "*"
|
||||
|
||||
def test_no_bedrock_mantle_wildcard(self):
|
||||
policy = _captured_policy()
|
||||
stmt = _statement_by_sid(policy, "BedrockMantleLiteLLM")
|
||||
actions = stmt["Action"]
|
||||
if isinstance(actions, str):
|
||||
actions = [actions]
|
||||
assert "bedrock-mantle:*" not in actions, (
|
||||
"session policy must not grant bedrock-mantle:* — "
|
||||
"the ceiling should match the documented action set"
|
||||
)
|
||||
|
||||
def test_bedrock_mantle_statement_carries_secure_transport_condition(self):
|
||||
policy = _captured_policy()
|
||||
stmt = _statement_by_sid(policy, "BedrockMantleLiteLLM")
|
||||
cond = stmt.get("Condition") or {}
|
||||
assert cond.get("Bool", {}).get("aws:SecureTransport") == "true", (
|
||||
"BedrockMantleLiteLLM must require aws:SecureTransport=true "
|
||||
"to keep parity with the bedrock statement"
|
||||
)
|
||||
|
||||
|
||||
def _make_jwt(payload: dict) -> str:
|
||||
def _segment(data: dict) -> str:
|
||||
return base64.urlsafe_b64encode(json.dumps(data).encode()).rstrip(b"=").decode()
|
||||
|
|
|
|||
|
|
@ -44,6 +44,60 @@ class TestOpenAIResponsesAPIConfig:
|
|||
# The function should return the params unchanged
|
||||
assert result == test_params
|
||||
|
||||
@pytest.mark.parametrize("max_output_tokens", [1, 15])
|
||||
def test_map_openai_params_clamps_max_output_tokens_below_minimum(self, max_output_tokens):
|
||||
"""OpenAI's Responses API rejects max_output_tokens < 16.
|
||||
|
||||
Claude Code (via the Anthropic Messages -> Responses adapter) sends a
|
||||
max_tokens=1 warmup probe when running `/model`, which produced:
|
||||
"Invalid 'max_output_tokens': integer below minimum value.
|
||||
Expected a value >= 16, but got 1 instead."
|
||||
Clamp anything below the minimum up to 16 instead of erroring.
|
||||
"""
|
||||
result = self.config.map_openai_params(
|
||||
response_api_optional_params={"max_output_tokens": max_output_tokens},
|
||||
model=self.model,
|
||||
drop_params=False,
|
||||
)
|
||||
|
||||
assert result["max_output_tokens"] == 16
|
||||
|
||||
def test_map_openai_params_preserves_max_output_tokens_at_or_above_minimum(self):
|
||||
"""Values already >= 16 must pass through untouched."""
|
||||
result = self.config.map_openai_params(
|
||||
response_api_optional_params={"max_output_tokens": 256},
|
||||
model=self.model,
|
||||
drop_params=False,
|
||||
)
|
||||
|
||||
assert result["max_output_tokens"] == 256
|
||||
|
||||
def test_map_openai_params_leaves_max_output_tokens_absent(self):
|
||||
"""A request without max_output_tokens must not gain the key."""
|
||||
result = self.config.map_openai_params(
|
||||
response_api_optional_params={"input": "hi"},
|
||||
model=self.model,
|
||||
drop_params=False,
|
||||
)
|
||||
|
||||
assert "max_output_tokens" not in result
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value, expected",
|
||||
[
|
||||
(1, 16),
|
||||
(15, 16),
|
||||
(16, 16),
|
||||
(17, 17),
|
||||
(256, 256),
|
||||
(None, None),
|
||||
],
|
||||
)
|
||||
def test_enforce_min_max_output_tokens(self, value, expected):
|
||||
"""Below the minimum clamps to 16; the boundary, larger values, and None
|
||||
are returned unchanged so no previously-valid request regresses."""
|
||||
assert self.config._enforce_min_max_output_tokens(value) == expected
|
||||
|
||||
def validate_responses_api_request_params(self, params, expected_fields):
|
||||
"""
|
||||
Validate that the params dict has the expected structure of ResponsesAPIRequestParams
|
||||
|
|
|
|||
|
|
@ -4910,6 +4910,7 @@ class TestMCPDcrBridgeDelegateAdmission:
|
|||
cls,
|
||||
*,
|
||||
key_hash=None,
|
||||
user_id=None,
|
||||
server_id="bridge-server-id",
|
||||
access_token="inner-upstream-access-token",
|
||||
token_type="Bearer",
|
||||
|
|
@ -4921,17 +4922,23 @@ class TestMCPDcrBridgeDelegateAdmission:
|
|||
envelope_keys_from_master_key,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import (
|
||||
EnvelopeIdentity,
|
||||
SealedEnvelope,
|
||||
UpstreamTokenGrant,
|
||||
key_hash_identity,
|
||||
mint_envelope,
|
||||
user_identity,
|
||||
)
|
||||
from pydantic import SecretStr
|
||||
|
||||
identity = (
|
||||
user_identity(server_id=server_id, user_id=user_id)
|
||||
if user_id is not None
|
||||
else key_hash_identity(server_id=server_id, key_hash=key_hash or cls._KEY_HASH)
|
||||
)
|
||||
keys = envelope_keys_from_master_key(master_key or cls._MASTER_KEY)
|
||||
now = minted_at or datetime.now(timezone.utc)
|
||||
sealed = mint_envelope(
|
||||
identity=EnvelopeIdentity(server_id=server_id, key_hash=key_hash or cls._KEY_HASH),
|
||||
identity=identity,
|
||||
grant=UpstreamTokenGrant(
|
||||
access_token=SecretStr(access_token),
|
||||
token_type=token_type,
|
||||
|
|
@ -4999,6 +5006,38 @@ class TestMCPDcrBridgeDelegateAdmission:
|
|||
stack.enter_context(patcher)
|
||||
yield get_key_object
|
||||
|
||||
@staticmethod
|
||||
@contextlib.contextmanager
|
||||
def _patch_user_reload(*, return_value=None, side_effect=None):
|
||||
"""Patch the user-subject reload path an interactively-minted envelope takes: the
|
||||
``get_user_object`` lookup ``_reload_admitted_user`` runs (which also drives the SCIM gate),
|
||||
plus the ``prisma_client`` / ``user_api_key_cache`` globals. The centralized gate's own
|
||||
fetches fail-safe to None under the MagicMock prisma, so an unblocked user admits. Yields the
|
||||
``get_user_object`` mock so a caller can assert the sealed user_id was the reload key."""
|
||||
get_user_object = AsyncMock(return_value=return_value, side_effect=side_effect)
|
||||
with (
|
||||
patch("litellm.proxy.auth.auth_checks.get_user_object", get_user_object),
|
||||
patch("litellm.proxy.proxy_server.prisma_client", MagicMock()),
|
||||
patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()),
|
||||
):
|
||||
yield get_user_object
|
||||
|
||||
@staticmethod
|
||||
def _wrapped_user_lookup_error(original: BaseException) -> ValueError:
|
||||
"""Reproduce get_user_object's real exception contract (litellm/proxy/auth/auth_checks.py): it
|
||||
catches every DB failure in a broad ``except`` and re-raises a bare ``ValueError``, so the
|
||||
original error (a missing-user Exception or a real outage) survives only as ``__context__``.
|
||||
Injecting a raw ConnectionError/Exception instead would exercise a shape production never
|
||||
produces and let a chain-blind outage classifier pass. That wrapping fidelity is itself pinned by
|
||||
test_get_user_object_wraps_db_outage_as_valueerror_preserving_context in test_auth_checks."""
|
||||
try:
|
||||
raise original
|
||||
except BaseException:
|
||||
try:
|
||||
raise ValueError(f"User doesn't exist in db. Got error - {original}")
|
||||
except ValueError as wrapped:
|
||||
return wrapped
|
||||
|
||||
@staticmethod
|
||||
def _mcp_request(path="/mcp/bridge_delegate_server"):
|
||||
"""A minimal ``Request`` for direct ``_admit_dcr_bridge_delegate`` calls, mirroring how
|
||||
|
|
@ -5060,6 +5099,155 @@ class TestMCPDcrBridgeDelegateAdmission:
|
|||
"bridge_delegate_server": {"Authorization": "Bearer inner-upstream-access-token"}
|
||||
}
|
||||
|
||||
async def test_user_subject_envelope_admits_under_the_reloaded_user(self):
|
||||
"""An interactively-minted (user_id) envelope admits under the reloaded USER, not a key: the
|
||||
reload is keyed by the sealed user_id, the admitted auth carries that user_id, the raw-key
|
||||
pipeline is never invoked, and the inner upstream token is injected for egress. This is the
|
||||
interactive-DCR admission the whole flow exists for."""
|
||||
envelope = self._mint_bridge_envelope(user_id="sso-user-7")
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/mcp/bridge_delegate_server",
|
||||
"headers": [(b"authorization", f"Bearer {envelope}".encode("latin-1"))],
|
||||
}
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_auth,
|
||||
patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr,
|
||||
patch("litellm.proxy.proxy_server.master_key", self._MASTER_KEY),
|
||||
self._patch_user_reload(
|
||||
return_value=MagicMock(
|
||||
user_id="sso-user-7",
|
||||
metadata={"scim_active": True},
|
||||
user_role=None,
|
||||
object_permission=None,
|
||||
object_permission_id=None,
|
||||
)
|
||||
) as get_user_object,
|
||||
):
|
||||
mock_mgr.get_mcp_server_by_name.return_value = self._bridge_delegate_server()
|
||||
(auth_result, _h, _s, mcp_server_auth_headers, _o, _r) = await MCPRequestHandler.process_mcp_request(scope)
|
||||
|
||||
assert get_user_object.await_args.kwargs["user_id"] == "sso-user-7"
|
||||
assert auth_result.user_id == "sso-user-7"
|
||||
mock_auth.assert_not_called()
|
||||
assert mcp_server_auth_headers == {
|
||||
"bridge_delegate_server": {"Authorization": "Bearer inner-upstream-access-token"}
|
||||
}
|
||||
|
||||
async def test_user_subject_envelope_carries_the_users_mcp_object_permission(self):
|
||||
"""The admitted user's own MCP object permission rides on the returned auth so the shared
|
||||
get_allowed_mcp_servers grants the user their litellm-granted servers, rather than admitting a
|
||||
bare user with no MCP access. Regression for the signed-in SSO client getting zero tools because
|
||||
the reload dropped the user's object permission."""
|
||||
object_permission = LiteLLM_ObjectPermissionTable(
|
||||
object_permission_id="op-user-7", mcp_servers=["bridge_delegate_server"]
|
||||
)
|
||||
envelope = self._mint_bridge_envelope(user_id="sso-user-7")
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/mcp/bridge_delegate_server",
|
||||
"headers": [(b"authorization", f"Bearer {envelope}".encode("latin-1"))],
|
||||
}
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth",
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr,
|
||||
patch("litellm.proxy.proxy_server.master_key", self._MASTER_KEY),
|
||||
self._patch_user_reload(
|
||||
return_value=MagicMock(
|
||||
user_id="sso-user-7",
|
||||
metadata={"scim_active": True},
|
||||
user_role=None,
|
||||
object_permission=object_permission,
|
||||
object_permission_id="op-user-7",
|
||||
)
|
||||
),
|
||||
):
|
||||
mock_mgr.get_mcp_server_by_name.return_value = self._bridge_delegate_server()
|
||||
(auth_result, _h, _s, _headers, _o, _r) = await MCPRequestHandler.process_mcp_request(scope)
|
||||
|
||||
assert auth_result.object_permission is not None
|
||||
assert auth_result.object_permission.mcp_servers == ["bridge_delegate_server"]
|
||||
|
||||
async def test_user_subject_envelope_missing_user_fails_closed_401(self):
|
||||
"""A user_id envelope whose user has since been deleted must fail closed with a 401, not a 500.
|
||||
get_user_object catches the missing row and re-raises a bare ValueError (it does not return None
|
||||
on the production path), so the reload must fail closed rather than let it propagate as an opaque
|
||||
500, and must not mistake the wrapped ValueError for a DB outage. Regression for the missing-user
|
||||
path surfacing as a 500."""
|
||||
envelope = self._mint_bridge_envelope(user_id="ghost-user")
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/mcp/bridge_delegate_server",
|
||||
"headers": [(b"authorization", f"Bearer {envelope}".encode("latin-1"))],
|
||||
}
|
||||
with (
|
||||
patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr,
|
||||
patch("litellm.proxy.proxy_server.master_key", self._MASTER_KEY),
|
||||
self._patch_user_reload(side_effect=self._wrapped_user_lookup_error(Exception())),
|
||||
):
|
||||
mock_mgr.get_mcp_server_by_name.return_value = self._bridge_delegate_server()
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await MCPRequestHandler.process_mcp_request(scope)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
async def test_user_subject_envelope_db_outage_is_retryable_503(self):
|
||||
"""A transient database outage while reloading the envelope's user is a retryable 503, not an
|
||||
opaque 500, matching the key path's contract so an interactive DCR client retries instead of
|
||||
treating a live identity as invalid. get_user_object wraps the outage in a bare ValueError, so this
|
||||
exercises the chain-aware classifier; a raw ConnectionError would falsely pass even the old
|
||||
chain-blind check because it is an OSError. Regression for the user reload dropping the 503 arm."""
|
||||
envelope = self._mint_bridge_envelope(user_id="sso-user-7")
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/mcp/bridge_delegate_server",
|
||||
"headers": [(b"authorization", f"Bearer {envelope}".encode("latin-1"))],
|
||||
}
|
||||
with (
|
||||
patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr,
|
||||
patch("litellm.proxy.proxy_server.master_key", self._MASTER_KEY),
|
||||
self._patch_user_reload(
|
||||
side_effect=self._wrapped_user_lookup_error(ConnectionError("auth database unreachable"))
|
||||
),
|
||||
):
|
||||
mock_mgr.get_mcp_server_by_name.return_value = self._bridge_delegate_server()
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await MCPRequestHandler.process_mcp_request(scope)
|
||||
|
||||
assert exc_info.value.status_code == 503
|
||||
|
||||
async def test_user_subject_envelope_scim_deactivated_user_fails_closed_401(self):
|
||||
"""SCIM-deactivating the envelope's user revokes it immediately: the reloaded user carries
|
||||
scim_active False, so admission 401s rather than letting an offboarded user keep tool access
|
||||
until the envelope expires."""
|
||||
envelope = self._mint_bridge_envelope(user_id="offboarded-user")
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/mcp/bridge_delegate_server",
|
||||
"headers": [(b"authorization", f"Bearer {envelope}".encode("latin-1"))],
|
||||
}
|
||||
with (
|
||||
patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr,
|
||||
patch("litellm.proxy.proxy_server.master_key", self._MASTER_KEY),
|
||||
self._patch_user_reload(return_value=MagicMock(user_id="offboarded-user", metadata={"scim_active": False})),
|
||||
):
|
||||
mock_mgr.get_mcp_server_by_name.return_value = self._bridge_delegate_server()
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await MCPRequestHandler.process_mcp_request(scope)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
async def test_revoked_key_envelope_fails_closed_401(self):
|
||||
"""An envelope whose key has since been deleted must fail closed: ``get_key_object`` raises
|
||||
for the missing row, so admission 401s instead of admitting the caller as an unrestricted
|
||||
|
|
|
|||
|
|
@ -0,0 +1,147 @@
|
|||
"""Classification matrix for upstream OAuth/DCR rejections: who is blamed depends only on the §5.2
|
||||
code and whose credentials the gateway presented, never on the upstream's HTTP status."""
|
||||
|
||||
import httpx
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.faults.classify import (
|
||||
classify_upstream_dcr_rejection,
|
||||
classify_upstream_token_rejection,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.faults.types import (
|
||||
CallerRejected,
|
||||
GatewayRejected,
|
||||
UpstreamProtocolFault,
|
||||
UpstreamReportedFault,
|
||||
)
|
||||
|
||||
|
||||
def _response(status_code: int, *, json_body: object = None, text_body: str = "", headers: dict = None) -> httpx.Response:
|
||||
request = httpx.Request("POST", "https://idp.example.com/token")
|
||||
if json_body is not None:
|
||||
return httpx.Response(status_code, json=json_body, request=request)
|
||||
return httpx.Response(status_code, text=text_body, headers=headers or {}, request=request)
|
||||
|
||||
|
||||
def test_caller_fault_code_classifies_as_caller_rejected_regardless_of_status():
|
||||
fault = classify_upstream_token_rejection(
|
||||
_response(500, json_body={"error": "invalid_grant", "error_description": "Code expired."}),
|
||||
credential_source="gateway_stored",
|
||||
log_context="srv",
|
||||
)
|
||||
assert isinstance(fault, CallerRejected)
|
||||
assert fault.code == "invalid_grant"
|
||||
assert fault.description == "Code expired."
|
||||
|
||||
|
||||
def test_credential_code_with_gateway_stored_credentials_indicts_gateway():
|
||||
fault = classify_upstream_token_rejection(
|
||||
_response(401, json_body={"error": "invalid_client", "error_description": "not found"}),
|
||||
credential_source="gateway_stored",
|
||||
log_context="srv",
|
||||
)
|
||||
assert isinstance(fault, GatewayRejected)
|
||||
assert fault.code == "invalid_client"
|
||||
|
||||
|
||||
def test_credential_code_with_caller_supplied_credentials_stays_caller_fault():
|
||||
fault = classify_upstream_token_rejection(
|
||||
_response(401, json_body={"error": "invalid_client"}),
|
||||
credential_source="caller_supplied",
|
||||
log_context="srv",
|
||||
)
|
||||
assert isinstance(fault, CallerRejected)
|
||||
assert fault.code == "invalid_client"
|
||||
|
||||
|
||||
def test_unknown_code_relays_as_caller_rejected():
|
||||
fault = classify_upstream_token_rejection(
|
||||
_response(400, json_body={"error": "slow_down", "error_description": "Polling too fast."}),
|
||||
credential_source="gateway_stored",
|
||||
log_context="srv",
|
||||
)
|
||||
assert isinstance(fault, CallerRejected)
|
||||
assert fault.code == "slow_down"
|
||||
|
||||
|
||||
def test_body_without_error_field_is_protocol_fault():
|
||||
fault = classify_upstream_token_rejection(
|
||||
_response(404, text_body="<html>not here</html>"),
|
||||
credential_source="gateway_stored",
|
||||
log_context="srv",
|
||||
)
|
||||
assert isinstance(fault, UpstreamProtocolFault)
|
||||
assert fault.note == "upstream token endpoint returned HTTP 404"
|
||||
|
||||
|
||||
def test_unreadable_body_is_protocol_fault_not_exception():
|
||||
unreadable = httpx.Response(
|
||||
400,
|
||||
stream=httpx.ByteStream(b"\x1f\x8bnot-gzip"),
|
||||
headers={"content-encoding": "gzip"},
|
||||
request=httpx.Request("POST", "https://idp.example.com/token"),
|
||||
)
|
||||
fault = classify_upstream_token_rejection(unreadable, credential_source="gateway_stored", log_context="srv")
|
||||
assert isinstance(fault, UpstreamProtocolFault)
|
||||
|
||||
|
||||
def test_wire_fields_are_bounded():
|
||||
fault = classify_upstream_token_rejection(
|
||||
_response(400, json_body={"error": "invalid_request", "error_description": "x" * 5000}),
|
||||
credential_source="gateway_stored",
|
||||
log_context="srv",
|
||||
)
|
||||
assert isinstance(fault, CallerRejected)
|
||||
assert len(fault.description) == 500
|
||||
|
||||
|
||||
def test_dcr_rejection_with_rfc7591_code_is_caller_rejected():
|
||||
fault = classify_upstream_dcr_rejection(
|
||||
_response(400, json_body={"error": "invalid_redirect_uri", "error_description": "not allowed"}),
|
||||
log_context="srv",
|
||||
)
|
||||
assert isinstance(fault, CallerRejected)
|
||||
assert fault.code == "invalid_redirect_uri"
|
||||
|
||||
|
||||
def test_dcr_rejection_without_code_is_protocol_fault():
|
||||
fault = classify_upstream_dcr_rejection(_response(500, text_body="<html>trace</html>"), log_context="srv")
|
||||
assert isinstance(fault, UpstreamProtocolFault)
|
||||
assert fault.note == "upstream registration failed with HTTP 500"
|
||||
|
||||
|
||||
def test_upstream_self_blame_codes_stay_upstream_faults():
|
||||
fault = classify_upstream_token_rejection(
|
||||
_response(400, json_body={"error": "server_error", "error_description": "boom"}),
|
||||
credential_source="caller_supplied",
|
||||
log_context="srv",
|
||||
)
|
||||
assert isinstance(fault, UpstreamReportedFault)
|
||||
assert fault.code == "server_error"
|
||||
|
||||
|
||||
def test_temporarily_unavailable_is_upstream_fault():
|
||||
fault = classify_upstream_token_rejection(
|
||||
_response(503, json_body={"error": "temporarily_unavailable"}),
|
||||
credential_source="gateway_stored",
|
||||
log_context="srv",
|
||||
)
|
||||
assert isinstance(fault, UpstreamReportedFault)
|
||||
assert fault.code == "temporarily_unavailable"
|
||||
|
||||
|
||||
def test_invalid_target_is_gateway_fault_even_with_caller_credentials():
|
||||
fault = classify_upstream_token_rejection(
|
||||
_response(400, json_body={"error": "invalid_target"}),
|
||||
credential_source="caller_supplied",
|
||||
log_context="srv",
|
||||
)
|
||||
assert isinstance(fault, GatewayRejected)
|
||||
assert fault.code == "invalid_target"
|
||||
|
||||
|
||||
def test_dcr_server_error_code_is_not_blamed_on_caller():
|
||||
fault = classify_upstream_dcr_rejection(
|
||||
_response(500, json_body={"error": "server_error"}),
|
||||
log_context="srv",
|
||||
)
|
||||
assert isinstance(fault, UpstreamReportedFault)
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
"""Rendering contract: status, wire code, and prose all derive from the fault tag, so a caller-fault
|
||||
code can never ship on a server-fault status and gateway-side faults never carry provider prose."""
|
||||
|
||||
import json
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.faults.render_oauth import (
|
||||
dcr_fault_detail,
|
||||
render_token_fault,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.faults.types import (
|
||||
CallerRejected,
|
||||
GatewayRejected,
|
||||
UpstreamProtocolFault,
|
||||
UpstreamReportedFault,
|
||||
)
|
||||
|
||||
|
||||
def test_caller_rejected_renders_code_derived_status():
|
||||
response = render_token_fault(CallerRejected(code="invalid_grant", description="Code expired."))
|
||||
assert response.status_code == 400
|
||||
assert json.loads(response.body) == {"error": "invalid_grant", "error_description": "Code expired."}
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
def test_caller_rejected_invalid_client_renders_401():
|
||||
response = render_token_fault(CallerRejected(code="invalid_client"))
|
||||
assert response.status_code == 401
|
||||
assert json.loads(response.body) == {"error": "invalid_client"}
|
||||
|
||||
|
||||
def test_caller_rejected_includes_error_uri_only_when_present():
|
||||
response = render_token_fault(
|
||||
CallerRejected(code="invalid_scope", description="bad scope", error_uri="https://idp.example.com/e")
|
||||
)
|
||||
assert json.loads(response.body) == {
|
||||
"error": "invalid_scope",
|
||||
"error_description": "bad scope",
|
||||
"error_uri": "https://idp.example.com/e",
|
||||
}
|
||||
|
||||
|
||||
def test_gateway_rejected_renders_502_with_gateway_prose():
|
||||
response = render_token_fault(GatewayRejected(code="invalid_client"))
|
||||
assert response.status_code == 502
|
||||
body = json.loads(response.body)
|
||||
assert body["error"] == "server_error"
|
||||
assert "invalid_client" in body["error_description"]
|
||||
assert "client_id and client_secret" in body["error_description"]
|
||||
|
||||
|
||||
def test_gateway_invalid_target_prose_names_resource_indicators():
|
||||
response = render_token_fault(GatewayRejected(code="invalid_target"))
|
||||
body = json.loads(response.body)
|
||||
assert response.status_code == 502
|
||||
assert "RFC 8707" in body["error_description"]
|
||||
|
||||
|
||||
def test_protocol_fault_renders_502_note():
|
||||
response = render_token_fault(UpstreamProtocolFault(note="upstream token endpoint returned HTTP 503"))
|
||||
assert response.status_code == 502
|
||||
assert json.loads(response.body) == {
|
||||
"error": "server_error",
|
||||
"error_description": "upstream token endpoint returned HTTP 503",
|
||||
}
|
||||
|
||||
|
||||
def test_dcr_caller_rejection_is_400_per_rfc7591_regardless_of_upstream_status():
|
||||
status_code, detail = dcr_fault_detail(CallerRejected(code="invalid_client_metadata", description="bad grant types"))
|
||||
assert status_code == 400
|
||||
assert detail == "invalid_client_metadata: bad grant types"
|
||||
|
||||
|
||||
def test_dcr_protocol_fault_is_502():
|
||||
status_code, detail = dcr_fault_detail(UpstreamProtocolFault(note="upstream registration failed with HTTP 500"))
|
||||
assert status_code == 502
|
||||
assert detail == "upstream registration failed with HTTP 500"
|
||||
|
||||
|
||||
def test_upstream_reported_server_error_renders_502_with_matching_code():
|
||||
response = render_token_fault(UpstreamReportedFault(code="server_error"))
|
||||
assert response.status_code == 502
|
||||
assert json.loads(response.body)["error"] == "server_error"
|
||||
|
||||
|
||||
def test_upstream_reported_temporarily_unavailable_renders_503_with_matching_code():
|
||||
response = render_token_fault(UpstreamReportedFault(code="temporarily_unavailable"))
|
||||
assert response.status_code == 503
|
||||
body = json.loads(response.body)
|
||||
assert body["error"] == "temporarily_unavailable"
|
||||
assert "retry" in body["error_description"]
|
||||
|
||||
|
||||
def test_dcr_upstream_reported_fault_maps_to_5xx():
|
||||
status_code, detail = dcr_fault_detail(UpstreamReportedFault(code="server_error"))
|
||||
assert status_code == 502
|
||||
assert "internal error" in detail
|
||||
|
|
@ -15,10 +15,14 @@ from pydantic import SecretStr
|
|||
from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import (
|
||||
BridgeEnvelopeAdmitted,
|
||||
BridgeEnvelopeInvalid,
|
||||
BridgeRefreshInvalid,
|
||||
BridgeRefreshOpened,
|
||||
NotBridgeEnvelope,
|
||||
build_bridge_refresh_token_response,
|
||||
build_bridge_token_response,
|
||||
envelope_keys_from_master_key,
|
||||
is_bridge_envelope_shaped,
|
||||
open_bridge_refresh_envelope,
|
||||
resolve_bridge_envelope,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import (
|
||||
|
|
@ -26,15 +30,17 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import
|
|||
EnvelopeIdentity,
|
||||
EnvelopeKeys,
|
||||
EnvelopeTooLarge,
|
||||
RefreshCredential,
|
||||
SealedEnvelope,
|
||||
UpstreamTokenGrant,
|
||||
key_hash_identity,
|
||||
mint_envelope,
|
||||
)
|
||||
|
||||
_NOW = datetime(2026, 7, 9, 12, 0, 0, tzinfo=timezone.utc)
|
||||
_MASTER_KEY = "sk-master-key-for-derivation-tests-0123456789"
|
||||
_ACCESS_TOKEN = "upstream-access-token-do-not-leak-8f14e45fceea"
|
||||
_IDENTITY = EnvelopeIdentity(server_id="srv-456", key_hash="hashed-key-123")
|
||||
_IDENTITY = key_hash_identity(server_id="srv-456", key_hash="hashed-key-123")
|
||||
_SERVER_ID = _IDENTITY.server_id
|
||||
|
||||
|
||||
|
|
@ -48,6 +54,76 @@ def _sealed_token(keys: EnvelopeKeys, now: datetime = _NOW, identity: EnvelopeId
|
|||
return sealed.token.get_secret_value()
|
||||
|
||||
|
||||
_UPSTREAM_REFRESH = "upstream-refresh-do-not-leak-9b2c"
|
||||
|
||||
|
||||
def _sealed_refresh(keys: EnvelopeKeys, now: datetime = _NOW, identity: EnvelopeIdentity = _IDENTITY) -> str:
|
||||
sealed = build_bridge_refresh_token_response(
|
||||
identity, RefreshCredential(refresh_token=SecretStr(_UPSTREAM_REFRESH)), keys, now
|
||||
)
|
||||
assert isinstance(sealed, SealedEnvelope)
|
||||
return sealed.token.get_secret_value()
|
||||
|
||||
|
||||
def test_open_bridge_refresh_envelope_round_trips_identity_and_refresh():
|
||||
keys = envelope_keys_from_master_key(_MASTER_KEY)
|
||||
result = open_bridge_refresh_envelope(_sealed_refresh(keys), keys, _NOW, _SERVER_ID)
|
||||
assert isinstance(result, BridgeRefreshOpened)
|
||||
assert result.identity == _IDENTITY
|
||||
assert result.refresh.refresh_token.get_secret_value() == _UPSTREAM_REFRESH
|
||||
|
||||
|
||||
def test_open_bridge_refresh_envelope_strips_bearer_scheme():
|
||||
keys = envelope_keys_from_master_key(_MASTER_KEY)
|
||||
result = open_bridge_refresh_envelope(f"Bearer {_sealed_refresh(keys)}", keys, _NOW, _SERVER_ID)
|
||||
assert isinstance(result, BridgeRefreshOpened)
|
||||
|
||||
|
||||
def test_open_bridge_refresh_envelope_rejects_wrong_server():
|
||||
keys = envelope_keys_from_master_key(_MASTER_KEY)
|
||||
result = open_bridge_refresh_envelope(_sealed_refresh(keys), keys, _NOW, "a-different-server")
|
||||
assert isinstance(result, BridgeRefreshInvalid)
|
||||
|
||||
|
||||
def test_open_bridge_refresh_envelope_rejects_non_refresh_bearers():
|
||||
keys = envelope_keys_from_master_key(_MASTER_KEY)
|
||||
# an access envelope is not a refresh envelope; a raw upstream refresh token is not one either
|
||||
assert isinstance(open_bridge_refresh_envelope(_sealed_token(keys), keys, _NOW, _SERVER_ID), BridgeRefreshInvalid)
|
||||
assert isinstance(open_bridge_refresh_envelope("raw-refresh-token", keys, _NOW, _SERVER_ID), BridgeRefreshInvalid)
|
||||
|
||||
|
||||
def test_open_bridge_refresh_envelope_rejects_under_wrong_master_key():
|
||||
minted = envelope_keys_from_master_key(_MASTER_KEY)
|
||||
other = envelope_keys_from_master_key(_MASTER_KEY + "-rotated")
|
||||
result = open_bridge_refresh_envelope(_sealed_refresh(minted), other, _NOW, _SERVER_ID)
|
||||
assert isinstance(result, BridgeRefreshInvalid)
|
||||
|
||||
|
||||
def test_refresh_envelope_is_never_admitted_at_the_tool_call_edge():
|
||||
"""A refresh envelope must never authenticate a tool call. The admission edge engages the bridge arm
|
||||
for it (is_bridge_envelope_shaped is true for either envelope kind), and the consumer rejects it as
|
||||
BridgeEnvelopeInvalid, which admission fails closed (401): a refresh credential is only ever
|
||||
presented back to the token endpoint."""
|
||||
keys = envelope_keys_from_master_key(_MASTER_KEY)
|
||||
refresh = _sealed_refresh(keys)
|
||||
assert is_bridge_envelope_shaped(refresh) is True
|
||||
assert is_bridge_envelope_shaped(f"Bearer {refresh}") is True
|
||||
result = resolve_bridge_envelope(refresh, keys, _NOW, _SERVER_ID)
|
||||
assert isinstance(result, BridgeEnvelopeInvalid)
|
||||
|
||||
|
||||
def test_refresh_jwt_wearing_the_access_prefix_is_rejected_at_the_edge():
|
||||
"""Belt-and-suspenders against a swapped wire prefix: a refresh JWT re-prefixed as an access envelope
|
||||
opens far enough to hit the signed kind claim, which rejects it, so admission fails closed rather
|
||||
than forwarding a refresh credential's contents upstream."""
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import REFRESH_ENVELOPE_PREFIX
|
||||
|
||||
keys = envelope_keys_from_master_key(_MASTER_KEY)
|
||||
swapped = ENVELOPE_PREFIX + _sealed_refresh(keys).removeprefix(REFRESH_ENVELOPE_PREFIX)
|
||||
result = resolve_bridge_envelope(swapped, keys, _NOW, _SERVER_ID)
|
||||
assert isinstance(result, BridgeEnvelopeInvalid)
|
||||
|
||||
|
||||
def test_key_derivation_is_deterministic():
|
||||
assert envelope_keys_from_master_key(_MASTER_KEY) == envelope_keys_from_master_key(_MASTER_KEY)
|
||||
|
||||
|
|
@ -138,7 +214,7 @@ def test_resolve_envelope_minted_for_another_server_is_invalid():
|
|||
captured or misrouted envelope cannot forward one server's upstream credential to
|
||||
another. The valid access token stays sealed; the mismatch alone fails the resolve."""
|
||||
keys = envelope_keys_from_master_key(_MASTER_KEY)
|
||||
other_server_identity = EnvelopeIdentity(server_id="srv-OTHER", key_hash=_IDENTITY.key_hash)
|
||||
other_server_identity = key_hash_identity(server_id="srv-OTHER", key_hash=_IDENTITY.subject)
|
||||
token = _sealed_token(keys, identity=other_server_identity)
|
||||
result = resolve_bridge_envelope(token, keys, _NOW, _SERVER_ID)
|
||||
assert isinstance(result, BridgeEnvelopeInvalid)
|
||||
|
|
@ -155,7 +231,7 @@ def test_resolve_non_ascii_server_id_stays_total_and_does_not_raise():
|
|||
unicode server_id); it stays total and returns a typed result. A matching non-ASCII id admits,
|
||||
a mismatching one is BridgeEnvelopeInvalid, and neither raises."""
|
||||
keys = envelope_keys_from_master_key(_MASTER_KEY)
|
||||
unicode_identity = EnvelopeIdentity(server_id="srv-café", key_hash=_IDENTITY.key_hash)
|
||||
unicode_identity = key_hash_identity(server_id="srv-café", key_hash=_IDENTITY.subject)
|
||||
token = _sealed_token(keys, identity=unicode_identity)
|
||||
assert isinstance(resolve_bridge_envelope(token, keys, _NOW, "srv-café"), BridgeEnvelopeAdmitted)
|
||||
assert isinstance(resolve_bridge_envelope(token, keys, _NOW, "srv-cafe"), BridgeEnvelopeInvalid)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import
|
|||
ENVELOPE_PREFIX,
|
||||
MAX_ENVELOPE_BYTES,
|
||||
MAX_ENVELOPE_TTL_SECONDS,
|
||||
MAX_REFRESH_ENVELOPE_TTL_SECONDS,
|
||||
REFRESH_ENVELOPE_PREFIX,
|
||||
BadSignature,
|
||||
DecryptFailed,
|
||||
EnvelopeIdentity,
|
||||
|
|
@ -33,11 +35,18 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import
|
|||
MalformedPayload,
|
||||
NotAnEnvelope,
|
||||
OpenedEnvelope,
|
||||
OpenedRefreshEnvelope,
|
||||
RefreshCredential,
|
||||
SealedEnvelope,
|
||||
UpstreamTokenGrant,
|
||||
is_envelope,
|
||||
is_refresh_envelope,
|
||||
key_hash_identity,
|
||||
mint_envelope,
|
||||
mint_refresh_envelope,
|
||||
open_envelope,
|
||||
open_refresh_envelope,
|
||||
user_identity,
|
||||
)
|
||||
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value, encrypt_value
|
||||
|
||||
|
|
@ -51,7 +60,7 @@ _WRONG_SIGNING = EnvelopeKeys(signing_key=SecretStr(_OTHER_SIGNING_KEY), encrypt
|
|||
_WRONG_ENCRYPTION = EnvelopeKeys(signing_key=SecretStr(_SIGNING_KEY), encryption_key=SecretStr(_OTHER_ENCRYPTION_KEY))
|
||||
_ACCESS_TOKEN = "upstream-access-token-do-not-leak-8f14e45fceea"
|
||||
_REFRESH_TOKEN = "upstream-refresh-token-do-not-leak-1d0aa4b7"
|
||||
_IDENTITY = EnvelopeIdentity(server_id="srv-456", key_hash="hashed-key-123")
|
||||
_IDENTITY = key_hash_identity(server_id="srv-456", key_hash="hashed-key-123")
|
||||
|
||||
|
||||
def _full_grant() -> UpstreamTokenGrant:
|
||||
|
|
@ -137,17 +146,99 @@ def test_minimal_grant_round_trips_without_none_leakage_into_claims():
|
|||
def test_claim_layout_and_no_plaintext_token_in_envelope():
|
||||
token = _sealed_token(_full_grant())
|
||||
claims = _unverified_claims(token)
|
||||
assert set(claims) == {"iss", "iat", "exp", "server_id", "key_hash", "grant"}
|
||||
assert set(claims) == {"iss", "iat", "exp", "kind", "server_id", "subject_type", "subject", "grant"}
|
||||
assert claims["iss"] == ENVELOPE_ISSUER
|
||||
assert claims["iat"] == int(_NOW.timestamp())
|
||||
assert claims["exp"] == int(_NOW.timestamp()) + 600
|
||||
assert claims["kind"] == "access"
|
||||
assert claims["server_id"] == "srv-456"
|
||||
assert claims["key_hash"] == "hashed-key-123"
|
||||
assert claims["subject_type"] == "key_hash"
|
||||
assert claims["subject"] == "hashed-key-123"
|
||||
assert _ACCESS_TOKEN not in token
|
||||
assert _ACCESS_TOKEN not in json.dumps(claims)
|
||||
assert _REFRESH_TOKEN not in json.dumps(claims)
|
||||
|
||||
|
||||
def _refresh_credential() -> RefreshCredential:
|
||||
return RefreshCredential(refresh_token=SecretStr(_REFRESH_TOKEN), scope="read:tools", expires_in=None)
|
||||
|
||||
|
||||
def _sealed_refresh_token(refresh: RefreshCredential | None = None, keys: EnvelopeKeys = _KEYS) -> str:
|
||||
sealed = mint_refresh_envelope(_IDENTITY, refresh or _refresh_credential(), keys, _NOW)
|
||||
assert isinstance(sealed, SealedEnvelope)
|
||||
return sealed.token.get_secret_value()
|
||||
|
||||
|
||||
def test_refresh_envelope_round_trips_identity_and_refresh_token():
|
||||
token = _sealed_refresh_token()
|
||||
assert is_refresh_envelope(token)
|
||||
assert not is_envelope(token)
|
||||
opened = open_refresh_envelope(token, _KEYS, _NOW)
|
||||
assert isinstance(opened, OpenedRefreshEnvelope)
|
||||
assert opened.identity == _IDENTITY
|
||||
assert opened.refresh.refresh_token.get_secret_value() == _REFRESH_TOKEN
|
||||
assert opened.refresh.scope == "read:tools"
|
||||
|
||||
|
||||
def test_refresh_envelope_ttl_is_min_of_upstream_refresh_lifetime_and_cap():
|
||||
short = mint_refresh_envelope(
|
||||
_IDENTITY, RefreshCredential(refresh_token=SecretStr("r"), expires_in=120), _KEYS, _NOW
|
||||
)
|
||||
assert isinstance(short, SealedEnvelope)
|
||||
assert short.expires_at == _NOW + timedelta(seconds=120)
|
||||
capped = mint_refresh_envelope(
|
||||
_IDENTITY,
|
||||
RefreshCredential(refresh_token=SecretStr("r"), expires_in=MAX_REFRESH_ENVELOPE_TTL_SECONDS + 86400),
|
||||
_KEYS,
|
||||
_NOW,
|
||||
)
|
||||
assert isinstance(capped, SealedEnvelope)
|
||||
assert capped.expires_at == _NOW + timedelta(seconds=MAX_REFRESH_ENVELOPE_TTL_SECONDS)
|
||||
default = mint_refresh_envelope(_IDENTITY, RefreshCredential(refresh_token=SecretStr("r")), _KEYS, _NOW)
|
||||
assert isinstance(default, SealedEnvelope)
|
||||
assert default.expires_at == _NOW + timedelta(seconds=MAX_REFRESH_ENVELOPE_TTL_SECONDS)
|
||||
|
||||
|
||||
def test_access_and_refresh_envelopes_do_not_cross_open():
|
||||
access = _sealed_token(_full_grant())
|
||||
refresh = _sealed_refresh_token()
|
||||
# each opener rejects the other kind's prefix outright
|
||||
assert isinstance(open_refresh_envelope(access, _KEYS, _NOW), NotAnEnvelope)
|
||||
assert isinstance(open_envelope(refresh, _KEYS, _NOW), NotAnEnvelope)
|
||||
|
||||
|
||||
def test_prefix_swap_is_rejected_by_the_signed_kind_claim():
|
||||
# the wire prefix is not signed, so swap it; the signed kind claim must still reject the cross-use
|
||||
refresh = _sealed_refresh_token()
|
||||
swapped_to_access = ENVELOPE_PREFIX + refresh.removeprefix(REFRESH_ENVELOPE_PREFIX)
|
||||
assert isinstance(open_envelope(swapped_to_access, _KEYS, _NOW), MalformedPayload)
|
||||
access = _sealed_token(_full_grant())
|
||||
swapped_to_refresh = REFRESH_ENVELOPE_PREFIX + access.removeprefix(ENVELOPE_PREFIX)
|
||||
assert isinstance(open_refresh_envelope(swapped_to_refresh, _KEYS, _NOW), MalformedPayload)
|
||||
|
||||
|
||||
def test_refresh_envelope_total_over_hostile_input():
|
||||
token = _sealed_refresh_token()
|
||||
# expired against the injected clock
|
||||
assert isinstance(
|
||||
open_refresh_envelope(token, _KEYS, _NOW + timedelta(seconds=MAX_REFRESH_ENVELOPE_TTL_SECONDS)), Expired
|
||||
)
|
||||
# wrong signing key
|
||||
assert isinstance(open_refresh_envelope(token, _WRONG_SIGNING, _NOW), BadSignature)
|
||||
# right signature, wrong encryption key
|
||||
assert isinstance(open_refresh_envelope(token, _WRONG_ENCRYPTION, _NOW), DecryptFailed)
|
||||
# not an envelope at all
|
||||
assert isinstance(open_refresh_envelope("raw-upstream-refresh-token", _KEYS, _NOW), NotAnEnvelope)
|
||||
|
||||
|
||||
def test_refresh_envelope_never_leaks_the_refresh_token_in_plaintext():
|
||||
token = _sealed_refresh_token()
|
||||
assert _REFRESH_TOKEN not in token
|
||||
claims = jwt.decode(token.removeprefix(REFRESH_ENVELOPE_PREFIX), options={"verify_signature": False})
|
||||
assert claims["kind"] == "refresh"
|
||||
assert _REFRESH_TOKEN not in json.dumps(claims)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"expires_in, expected_ttl",
|
||||
[
|
||||
|
|
@ -226,11 +317,11 @@ def test_wrong_issuer_is_malformed_payload():
|
|||
|
||||
def test_missing_identity_claim_is_malformed_payload():
|
||||
claims = _unverified_claims(_sealed_token(_full_grant()))
|
||||
forged = _forge({key: value for key, value in claims.items() if key != "key_hash"})
|
||||
forged = _forge({key: value for key, value in claims.items() if key != "subject"})
|
||||
assert isinstance(open_envelope(forged, _KEYS, _NOW), MalformedPayload)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("identity_claim", ["server_id", "key_hash"])
|
||||
@pytest.mark.parametrize("identity_claim", ["server_id", "subject"])
|
||||
def test_signed_empty_identity_claim_is_malformed_payload_not_a_raise(identity_claim):
|
||||
claims = _unverified_claims(_sealed_token(_full_grant()))
|
||||
forged = _forge({**claims, identity_claim: ""})
|
||||
|
|
@ -463,9 +554,11 @@ def test_non_positive_expires_in_is_rejected_at_construction_without_leaking():
|
|||
|
||||
def test_empty_identity_and_key_fields_are_rejected_at_construction():
|
||||
with pytest.raises(ValidationError):
|
||||
EnvelopeIdentity(server_id="", key_hash="hashed-key-123")
|
||||
EnvelopeIdentity(server_id="", subject_type="key_hash", subject="hashed-key-123")
|
||||
with pytest.raises(ValidationError):
|
||||
EnvelopeIdentity(server_id="srv-456", key_hash="")
|
||||
EnvelopeIdentity(server_id="srv-456", subject_type="key_hash", subject="")
|
||||
with pytest.raises(ValidationError):
|
||||
EnvelopeIdentity(server_id="srv-456", subject_type="not-a-subject-type", subject="x")
|
||||
with pytest.raises(ValidationError):
|
||||
EnvelopeKeys(signing_key=SecretStr(""), encryption_key=SecretStr(_ENCRYPTION_KEY))
|
||||
with pytest.raises(ValidationError):
|
||||
|
|
@ -474,6 +567,20 @@ def test_empty_identity_and_key_fields_are_rejected_at_construction():
|
|||
UpstreamTokenGrant(access_token=SecretStr(""), token_type="Bearer")
|
||||
|
||||
|
||||
def test_user_subject_identity_round_trips():
|
||||
"""The user_id subject variant seals and opens with its discriminator intact, so the edge can
|
||||
tell an interactively-minted (user) envelope from a scripted (key_hash) one and reload the right
|
||||
kind of record."""
|
||||
identity = user_identity(server_id="srv-456", user_id="user-42")
|
||||
sealed = mint_envelope(identity, _full_grant(), _KEYS, _NOW)
|
||||
assert isinstance(sealed, SealedEnvelope)
|
||||
opened = open_envelope(sealed.token.get_secret_value(), _KEYS, _NOW)
|
||||
assert isinstance(opened, OpenedEnvelope)
|
||||
assert opened.identity.server_id == "srv-456"
|
||||
assert opened.identity.subject_type == "user_id"
|
||||
assert opened.identity.subject == "user-42"
|
||||
|
||||
|
||||
def test_public_models_are_frozen():
|
||||
sealed = mint_envelope(_IDENTITY, _full_grant(), _KEYS, _NOW)
|
||||
assert isinstance(sealed, SealedEnvelope)
|
||||
|
|
@ -484,4 +591,4 @@ def test_public_models_are_frozen():
|
|||
with pytest.raises(ValidationError):
|
||||
opened.grant = _minimal_grant()
|
||||
with pytest.raises(ValidationError):
|
||||
_IDENTITY.key_hash = "someone-elses-hash"
|
||||
_IDENTITY.subject = "someone-elses-hash"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -701,6 +701,38 @@ async def test_default_internal_user_params_with_get_user_object(monkeypatch):
|
|||
assert creation_args["user_role"] == "internal_user"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_object_wraps_db_outage_as_valueerror_preserving_context():
|
||||
"""Pin get_user_object's exception contract: it catches every DB failure in a broad except and
|
||||
re-raises a bare ValueError, so a real outage survives only as __context__ rather than as the
|
||||
exception type. The MCP dcr_bridge admission and refresh paths depend on this to tell a transient
|
||||
outage (retry, 503) from a missing user (fail closed), which is why they classify across the cause
|
||||
chain instead of the top exception's type. If this wrapping ever changes, that classification must
|
||||
change with it, so this test guards the contract the callers rely on."""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
mock_prisma_client = MagicMock()
|
||||
mock_prisma_client.db = AsyncMock()
|
||||
mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(
|
||||
side_effect=ConnectionError("can't reach database server")
|
||||
)
|
||||
mock_cache = MagicMock()
|
||||
mock_cache.async_get_cache = AsyncMock(return_value=None)
|
||||
mock_cache.async_set_cache = AsyncMock()
|
||||
|
||||
with patch("litellm.proxy.auth.auth_checks._should_check_db", return_value=True):
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
await get_user_object(
|
||||
user_id="outage-contract-probe-user",
|
||||
prisma_client=mock_prisma_client,
|
||||
user_api_key_cache=mock_cache,
|
||||
user_id_upsert=False,
|
||||
proxy_logging_obj=None,
|
||||
)
|
||||
|
||||
assert isinstance(exc_info.value.__context__, ConnectionError)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_object_upsert_includes_user_email():
|
||||
"""Test that user_email is included when creating a new user via get_user_object upsert"""
|
||||
|
|
|
|||
|
|
@ -286,6 +286,46 @@ def test_is_database_service_unavailable_error_excludes_non_infra(error):
|
|||
)
|
||||
|
||||
|
||||
def _wrapped_like_get_user_object(original):
|
||||
"""Reproduce get_user_object's exception contract (litellm/proxy/auth/auth_checks.py): it catches
|
||||
every DB failure in a broad ``except`` and re-raises a bare ``ValueError``, so the original error
|
||||
survives only as ``__context__``. Building it by raising inside an ``except`` sets ``__context__``
|
||||
exactly as production does."""
|
||||
try:
|
||||
raise original
|
||||
except BaseException:
|
||||
try:
|
||||
raise ValueError("User doesn't exist in db. Got error - x")
|
||||
except ValueError as wrapped:
|
||||
return wrapped
|
||||
|
||||
|
||||
def test_is_database_service_unavailable_error_in_chain_sees_through_wrapping():
|
||||
"""The chain-aware classifier must see a real outage that a caller wrapped in a different type.
|
||||
get_user_object turns a connection error into a bare ValueError whose type check reads as non-infra,
|
||||
so the single-exception check returns False and only the chain walk recovers the outage. A missing
|
||||
user (whose wrapped cause is a plain Exception) must stay non-infra on both."""
|
||||
outage = _wrapped_like_get_user_object(ConnectionError("can't reach database server"))
|
||||
missing_user = _wrapped_like_get_user_object(Exception())
|
||||
|
||||
assert PrismaDBExceptionHandler.is_database_service_unavailable_error(outage) is False
|
||||
assert PrismaDBExceptionHandler.is_database_service_unavailable_error_in_chain(outage) is True
|
||||
assert PrismaDBExceptionHandler.is_database_service_unavailable_error_in_chain(missing_user) is False
|
||||
# parity: a raw outage with no wrapper is still an outage, and a plain ValueError is not
|
||||
assert PrismaDBExceptionHandler.is_database_service_unavailable_error_in_chain(ConnectionError("boom")) is True
|
||||
assert PrismaDBExceptionHandler.is_database_service_unavailable_error_in_chain(ValueError("nope")) is False
|
||||
|
||||
|
||||
def test_is_database_service_unavailable_error_in_chain_terminates_on_a_cause_cycle():
|
||||
"""The walk must terminate on a pathological __cause__ cycle rather than hang. Neither link is an
|
||||
outage, so the bounded walk returns False instead of looping forever."""
|
||||
first = ValueError("first")
|
||||
second = ValueError("second")
|
||||
first.__cause__ = second
|
||||
second.__cause__ = first
|
||||
assert PrismaDBExceptionHandler.is_database_service_unavailable_error_in_chain(first) is False
|
||||
|
||||
|
||||
def test_is_database_service_unavailable_error_asyncpg(monkeypatch):
|
||||
"""asyncpg connection/interface errors map to service-unavailable. asyncpg
|
||||
is not a hard dependency, so inject a stand-in module to exercise the
|
||||
|
|
|
|||
|
|
@ -608,3 +608,320 @@ class TestValidateFiniteSpend:
|
|||
with pytest.raises(HTTPException) as exc_info:
|
||||
validate_finite_spend(bad)
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
|
||||
class TestValidateFiniteSpendErrorDetail:
|
||||
"""The 400 for non-finite spend must carry the exact {"error": <msg>} body."""
|
||||
|
||||
def test_rejection_detail_is_exact(self):
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm.proxy.management_endpoints.common_utils import (
|
||||
validate_finite_spend,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
validate_finite_spend(float("nan"))
|
||||
|
||||
assert exc_info.value.detail == {
|
||||
"error": "spend must be a finite number. Received: nan"
|
||||
}
|
||||
|
||||
|
||||
class TestRequireCallerUserIdErrorDetail:
|
||||
"""The 403 for a service-account key must carry the exact error body."""
|
||||
|
||||
def test_rejection_detail_is_exact(self):
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm.proxy.management_endpoints.common_utils import (
|
||||
require_caller_user_id_for_non_admin,
|
||||
)
|
||||
|
||||
service_account_key = UserAPIKeyAuth(
|
||||
user_id=None,
|
||||
user_role=LitellmUserRoles.INTERNAL_USER,
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
require_caller_user_id_for_non_admin(service_account_key)
|
||||
|
||||
assert exc_info.value.detail == {
|
||||
"error": "Service-account keys cannot query user analytics. Use a user-bound key, or call as a proxy admin."
|
||||
}
|
||||
|
||||
|
||||
class TestCheckPassthroughRoutesCallerPermission:
|
||||
"""Only proxy admins may set allowed_passthrough_routes (top-level or under
|
||||
metadata); non-admins get a 403 naming the entity."""
|
||||
|
||||
def _non_admin(self):
|
||||
return UserAPIKeyAuth(
|
||||
user_id="u1", api_key="sk-x", user_role=LitellmUserRoles.INTERNAL_USER
|
||||
)
|
||||
|
||||
def test_top_level_routes_rejected_with_default_entity(self):
|
||||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from litellm.proxy.management_endpoints.common_utils import (
|
||||
_check_passthrough_routes_caller_permission,
|
||||
)
|
||||
|
||||
class _RouteData(BaseModel):
|
||||
allowed_passthrough_routes: list | None = None
|
||||
metadata: dict | None = None
|
||||
|
||||
data = _RouteData(allowed_passthrough_routes=["/v1/foo"])
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_check_passthrough_routes_caller_permission(data, self._non_admin())
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert exc_info.value.detail == {
|
||||
"error": "Only proxy admins can set `allowed_passthrough_routes` on a key."
|
||||
}
|
||||
|
||||
def test_metadata_routes_rejected_with_default_entity(self):
|
||||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from litellm.proxy.management_endpoints.common_utils import (
|
||||
_check_passthrough_routes_caller_permission,
|
||||
)
|
||||
|
||||
class _RouteData(BaseModel):
|
||||
allowed_passthrough_routes: list | None = None
|
||||
metadata: dict | None = None
|
||||
|
||||
data = _RouteData(metadata={"allowed_passthrough_routes": ["/v1/foo"]})
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_check_passthrough_routes_caller_permission(data, self._non_admin())
|
||||
|
||||
assert exc_info.value.detail == {
|
||||
"error": "Only proxy admins can set `metadata.allowed_passthrough_routes` on a key."
|
||||
}
|
||||
|
||||
def test_tolerates_data_missing_passthrough_and_metadata_fields(self):
|
||||
from pydantic import BaseModel
|
||||
|
||||
from litellm.proxy.management_endpoints.common_utils import (
|
||||
_check_passthrough_routes_caller_permission,
|
||||
)
|
||||
|
||||
class _Bare(BaseModel):
|
||||
unrelated: str = "x"
|
||||
|
||||
assert (
|
||||
_check_passthrough_routes_caller_permission(_Bare(), self._non_admin())
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
class TestIsUserOrgAdminForTeam:
|
||||
"""The caller must be looked up with its exact identity; a nulled or omitted
|
||||
lookup argument would silently mis-resolve org-admin status."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_object_called_with_caller_identity(self):
|
||||
from litellm.proxy.management_endpoints.common_utils import (
|
||||
_is_user_org_admin_for_team,
|
||||
)
|
||||
|
||||
team = LiteLLM_TeamTable(
|
||||
team_id="t1", organization_id="org1", members_with_roles=[]
|
||||
)
|
||||
key = UserAPIKeyAuth(
|
||||
user_id="u1", api_key="sk-x", user_role=LitellmUserRoles.INTERNAL_USER
|
||||
)
|
||||
fake_prisma, fake_cache, fake_logging = MagicMock(), MagicMock(), MagicMock()
|
||||
mock_get_user = AsyncMock(return_value=None)
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.proxy_server.prisma_client", fake_prisma
|
||||
), patch(
|
||||
"litellm.proxy.proxy_server.user_api_key_cache", fake_cache
|
||||
), patch(
|
||||
"litellm.proxy.proxy_server.proxy_logging_obj", fake_logging
|
||||
), patch(
|
||||
"litellm.proxy.auth.auth_checks.get_user_object", mock_get_user
|
||||
):
|
||||
result = await _is_user_org_admin_for_team(key, team)
|
||||
|
||||
assert result is False
|
||||
mock_get_user.assert_awaited_once_with(
|
||||
user_id="u1",
|
||||
prisma_client=fake_prisma,
|
||||
user_api_key_cache=fake_cache,
|
||||
user_id_upsert=False,
|
||||
proxy_logging_obj=fake_logging,
|
||||
)
|
||||
|
||||
|
||||
class TestTeamMemberHasPermission:
|
||||
def test_requires_caller_to_be_a_team_member(self):
|
||||
from litellm.proxy.management_endpoints.common_utils import (
|
||||
_team_member_has_permission,
|
||||
)
|
||||
|
||||
team = LiteLLM_TeamTable(
|
||||
team_id="t1",
|
||||
team_member_permissions=["/key/generate"],
|
||||
members_with_roles=[Member(user_id="someone-else", role="user")],
|
||||
)
|
||||
key = UserAPIKeyAuth(
|
||||
user_id="u1", api_key="sk-x", user_role=LitellmUserRoles.INTERNAL_USER
|
||||
)
|
||||
assert _team_member_has_permission(key, team, "/key/generate") is False
|
||||
|
||||
|
||||
class TestUserHasAdminPrivilegesGuard:
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_user_lookup_when_prisma_is_none(self):
|
||||
"""With no DB the guard short-circuits before any user lookup."""
|
||||
auth = UserAPIKeyAuth(
|
||||
user_id="user1", api_key="sk-x", user_role=LitellmUserRoles.INTERNAL_USER
|
||||
)
|
||||
mock_get_user = AsyncMock(return_value=None)
|
||||
with patch("litellm.proxy.auth.auth_checks.get_user_object", mock_get_user):
|
||||
result = await _user_has_admin_privileges(
|
||||
user_api_key_dict=auth, prisma_client=None
|
||||
)
|
||||
assert result is False
|
||||
mock_get_user.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_org_admin_membership_grants_privileges(self):
|
||||
"""With DB + user_id present, an ORG_ADMIN membership yields True."""
|
||||
auth = UserAPIKeyAuth(
|
||||
user_id="user1", api_key="sk-x", user_role=LitellmUserRoles.INTERNAL_USER
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
user_obj = LiteLLM_UserTable(
|
||||
user_id="user1",
|
||||
organization_memberships=[
|
||||
LiteLLM_OrganizationMembershipTable(
|
||||
user_id="user1",
|
||||
organization_id="org1",
|
||||
user_role=LitellmUserRoles.ORG_ADMIN.value,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
],
|
||||
)
|
||||
mock_get_user = AsyncMock(return_value=user_obj)
|
||||
with patch("litellm.proxy.auth.auth_checks.get_user_object", mock_get_user):
|
||||
result = await _user_has_admin_privileges(
|
||||
user_api_key_dict=auth, prisma_client=MagicMock()
|
||||
)
|
||||
assert result is True
|
||||
|
||||
|
||||
class TestAdminCanInviteUserGuard:
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_user_lookup_when_prisma_is_none(self):
|
||||
auth = UserAPIKeyAuth(
|
||||
user_id="admin1", api_key="sk-x", user_role=LitellmUserRoles.INTERNAL_USER
|
||||
)
|
||||
mock_get_user = AsyncMock(return_value=None)
|
||||
with patch("litellm.proxy.auth.auth_checks.get_user_object", mock_get_user):
|
||||
result = await admin_can_invite_user(
|
||||
target_user_id="target1",
|
||||
user_api_key_dict=auth,
|
||||
prisma_client=None,
|
||||
)
|
||||
assert result is False
|
||||
mock_get_user.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_org_admin_can_invite_user_in_shared_org(self):
|
||||
now = datetime.now(timezone.utc)
|
||||
auth = UserAPIKeyAuth(
|
||||
user_id="admin1", api_key="sk-x", user_role=LitellmUserRoles.INTERNAL_USER
|
||||
)
|
||||
|
||||
def membership(role):
|
||||
return LiteLLM_OrganizationMembershipTable(
|
||||
user_id="x",
|
||||
organization_id="org1",
|
||||
user_role=role,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
admin_obj = LiteLLM_UserTable(
|
||||
user_id="admin1",
|
||||
organization_memberships=[membership(LitellmUserRoles.ORG_ADMIN.value)],
|
||||
)
|
||||
target_obj = LiteLLM_UserTable(
|
||||
user_id="target1",
|
||||
organization_memberships=[membership(LitellmUserRoles.INTERNAL_USER.value)],
|
||||
)
|
||||
mock_get_user = AsyncMock(side_effect=[admin_obj, target_obj])
|
||||
with patch("litellm.proxy.auth.auth_checks.get_user_object", mock_get_user):
|
||||
result = await admin_can_invite_user(
|
||||
target_user_id="target1",
|
||||
user_api_key_dict=auth,
|
||||
prisma_client=MagicMock(),
|
||||
)
|
||||
assert result is True
|
||||
|
||||
|
||||
class TestTeamAdminCanInviteUserQuery:
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_many_queries_admin_teams_with_exact_where(self):
|
||||
mock_prisma = MagicMock()
|
||||
mock_auth = MagicMock()
|
||||
mock_auth.user_id = "admin"
|
||||
admin_user = LiteLLM_UserTable(user_id="admin", teams=["t1", "t2"])
|
||||
target_user = LiteLLM_UserTable(user_id="target", teams=["t2"])
|
||||
|
||||
def make_team(tid):
|
||||
obj = MagicMock()
|
||||
obj.team_id = tid
|
||||
obj.model_dump = lambda: {
|
||||
"team_id": tid,
|
||||
"members_with_roles": [{"user_id": "admin", "role": "admin"}],
|
||||
}
|
||||
return obj
|
||||
|
||||
find_many = AsyncMock(return_value=[make_team("t1"), make_team("t2")])
|
||||
mock_prisma.db.litellm_teamtable.find_many = find_many
|
||||
|
||||
await _team_admin_can_invite_user(
|
||||
user_api_key_dict=mock_auth,
|
||||
admin_user_obj=admin_user,
|
||||
target_user_obj=target_user,
|
||||
prisma_client=mock_prisma,
|
||||
)
|
||||
|
||||
find_many.assert_awaited_once_with(where={"team_id": {"in": ["t1", "t2"]}})
|
||||
|
||||
|
||||
class TestSetObjectMetadataFieldPremiumArg:
|
||||
def test_premium_check_receives_the_field_name(self):
|
||||
team = LiteLLM_TeamTable(team_id="t1", metadata={})
|
||||
with patch(
|
||||
"litellm.proxy.management_endpoints.common_utils._premium_user_check"
|
||||
) as mock_premium:
|
||||
_set_object_metadata_field(team, "guardrails", ["g1"])
|
||||
mock_premium.assert_called_once_with("guardrails")
|
||||
|
||||
|
||||
class TestUpdateMetadataFieldMove:
|
||||
def test_none_valued_field_is_not_moved_into_metadata(self):
|
||||
"""A None value must leave the field untouched (guard requires non-None)."""
|
||||
from litellm.proxy.management_endpoints.common_utils import (
|
||||
_update_metadata_field,
|
||||
)
|
||||
|
||||
updated_kv = {"guardrails": None}
|
||||
_update_metadata_field(updated_kv=updated_kv, field_name="guardrails")
|
||||
assert updated_kv == {"guardrails": None}
|
||||
|
||||
def test_set_premium_field_is_moved_into_metadata(self):
|
||||
updated_kv = {"guardrails": ["g1"]}
|
||||
with patch(
|
||||
"litellm.proxy.management_endpoints.common_utils._premium_user_check"
|
||||
):
|
||||
_update_metadata_fields(updated_kv)
|
||||
assert "guardrails" not in updated_kv
|
||||
assert updated_kv["metadata"]["guardrails"] == ["g1"]
|
||||
|
|
|
|||
|
|
@ -405,3 +405,98 @@ class TestResolveModelForCostLookup:
|
|||
|
||||
assert resolved_model == "azure/openai/gpt-5.3-codex"
|
||||
assert provider is None
|
||||
|
||||
def test_returns_custom_llm_provider_on_base_model_path(self):
|
||||
"""base_model path: the custom_llm_provider from litellm_params is
|
||||
returned as the second tuple element, unchanged."""
|
||||
from litellm.proxy.management_endpoints.cost_tracking_settings import (
|
||||
_resolve_model_for_cost_lookup,
|
||||
)
|
||||
|
||||
mock_router = MagicMock()
|
||||
mock_router.get_model_list.return_value = [
|
||||
{
|
||||
"model_name": "my-azure-model",
|
||||
"litellm_params": {
|
||||
"model": "azure/my-deployment",
|
||||
"base_model": "azure/gpt-4o",
|
||||
"custom_llm_provider": "azure",
|
||||
},
|
||||
"model_info": {"id": "test-id"},
|
||||
}
|
||||
]
|
||||
|
||||
with patch("litellm.proxy.proxy_server.llm_router", mock_router):
|
||||
resolved_model, provider = _resolve_model_for_cost_lookup("my-azure-model")
|
||||
|
||||
assert resolved_model == "azure/gpt-4o"
|
||||
assert provider == "azure"
|
||||
|
||||
def test_returns_custom_llm_provider_on_resolved_model_path(self):
|
||||
"""resolved-model path (no base_model): the custom_llm_provider from
|
||||
litellm_params is returned alongside litellm_params.model."""
|
||||
from litellm.proxy.management_endpoints.cost_tracking_settings import (
|
||||
_resolve_model_for_cost_lookup,
|
||||
)
|
||||
|
||||
mock_router = MagicMock()
|
||||
mock_router.get_model_list.return_value = [
|
||||
{
|
||||
"model_name": "gpt-4",
|
||||
"litellm_params": {
|
||||
"model": "openai/gpt-4",
|
||||
"custom_llm_provider": "openai",
|
||||
},
|
||||
"model_info": {"id": "test-id"},
|
||||
}
|
||||
]
|
||||
|
||||
with patch("litellm.proxy.proxy_server.llm_router", mock_router):
|
||||
resolved_model, provider = _resolve_model_for_cost_lookup("gpt-4")
|
||||
|
||||
assert resolved_model == "openai/gpt-4"
|
||||
assert provider == "openai"
|
||||
|
||||
def test_resolves_base_model_when_deployment_has_no_litellm_params(self):
|
||||
"""A deployment can omit litellm_params entirely; base_model from
|
||||
model_info must still resolve (the .get default must be {} not None,
|
||||
else the later litellm_params.get(...) raises and resolution is lost)."""
|
||||
from litellm.proxy.management_endpoints.cost_tracking_settings import (
|
||||
_resolve_model_for_cost_lookup,
|
||||
)
|
||||
|
||||
mock_router = MagicMock()
|
||||
mock_router.get_model_list.return_value = [
|
||||
{
|
||||
"model_name": "my-azure-model",
|
||||
"model_info": {"base_model": "azure/gpt-4o"},
|
||||
}
|
||||
]
|
||||
|
||||
with patch("litellm.proxy.proxy_server.llm_router", mock_router):
|
||||
resolved_model, provider = _resolve_model_for_cost_lookup("my-azure-model")
|
||||
|
||||
assert resolved_model == "azure/gpt-4o"
|
||||
assert provider is None
|
||||
|
||||
def test_resolves_model_when_deployment_has_no_model_info(self):
|
||||
"""A deployment can omit model_info entirely; litellm_params.model must
|
||||
still resolve (the .get default must be {} not None, else the earlier
|
||||
model_info.get(...) raises and resolution is lost)."""
|
||||
from litellm.proxy.management_endpoints.cost_tracking_settings import (
|
||||
_resolve_model_for_cost_lookup,
|
||||
)
|
||||
|
||||
mock_router = MagicMock()
|
||||
mock_router.get_model_list.return_value = [
|
||||
{
|
||||
"model_name": "gpt-4",
|
||||
"litellm_params": {"model": "openai/gpt-4"},
|
||||
}
|
||||
]
|
||||
|
||||
with patch("litellm.proxy.proxy_server.llm_router", mock_router):
|
||||
resolved_model, provider = _resolve_model_for_cost_lookup("gpt-4")
|
||||
|
||||
assert resolved_model == "openai/gpt-4"
|
||||
assert provider is None
|
||||
|
|
|
|||
|
|
@ -12272,6 +12272,55 @@ async def test_bulk_update_team_keys_team_member_no_permission(monkeypatch):
|
|||
mock.update_data.assert_not_called()
|
||||
|
||||
|
||||
def test_handle_key_type_persists_key_type_and_derives_routes():
|
||||
"""`handle_key_type` keeps `key_type` in the payload (so it is persisted on
|
||||
the token) while still deriving the `allowed_routes` preset. Regression for
|
||||
the UI showing scoped keys as "All Proxy Models": the frontend now reads the
|
||||
persisted `key_type` instead of reverse-mapping the preset string."""
|
||||
from litellm.proxy._types import GenerateKeyRequest, LiteLLMKeyType
|
||||
from litellm.proxy.management_endpoints.key_management_endpoints import (
|
||||
handle_key_type,
|
||||
)
|
||||
|
||||
cases = {
|
||||
LiteLLMKeyType.MANAGEMENT: ("management", ["management_routes"]),
|
||||
LiteLLMKeyType.READ_ONLY: ("read_only", ["info_routes"]),
|
||||
LiteLLMKeyType.LLM_API: ("llm_api", ["llm_api_routes"]),
|
||||
}
|
||||
for key_type, (expected_type, expected_routes) in cases.items():
|
||||
data = GenerateKeyRequest(key_type=key_type)
|
||||
out = handle_key_type(data, {"key_type": key_type})
|
||||
assert out["key_type"] == expected_type
|
||||
assert out["allowed_routes"] == expected_routes
|
||||
|
||||
|
||||
def test_handle_key_type_default_persists_type_without_forcing_routes():
|
||||
"""`default` is persisted but must not overwrite an explicit `allowed_routes`
|
||||
(e.g. a SCIM key created with `["/scim/*"]` and no explicit key_type)."""
|
||||
from litellm.proxy._types import GenerateKeyRequest, LiteLLMKeyType
|
||||
from litellm.proxy.management_endpoints.key_management_endpoints import (
|
||||
handle_key_type,
|
||||
)
|
||||
|
||||
data = GenerateKeyRequest(key_type=LiteLLMKeyType.DEFAULT)
|
||||
out = handle_key_type(data, {"allowed_routes": ["/scim/*"], "key_type": LiteLLMKeyType.DEFAULT})
|
||||
assert out["key_type"] == "default"
|
||||
assert out["allowed_routes"] == ["/scim/*"]
|
||||
|
||||
|
||||
def test_handle_key_type_none_drops_key_type():
|
||||
"""When no `key_type` is supplied the payload must not carry a `key_type`
|
||||
entry, so old keys stay `null` and the frontend keeps its route fallback."""
|
||||
from litellm.proxy._types import GenerateKeyRequest
|
||||
from litellm.proxy.management_endpoints.key_management_endpoints import (
|
||||
handle_key_type,
|
||||
)
|
||||
|
||||
data = GenerateKeyRequest(key_type=None)
|
||||
out = handle_key_type(data, {"key_type": None})
|
||||
assert "key_type" not in out
|
||||
|
||||
|
||||
# ---- pydantic-layer validation -------------------------------------------
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -14,13 +14,12 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
sys.path.insert(
|
||||
0, os.path.abspath("../../..")
|
||||
) # Adds the parent directory to the system path
|
||||
sys.path.insert(0, os.path.abspath("../../..")) # Adds the parent directory to the system path
|
||||
|
||||
import litellm
|
||||
from litellm import Router
|
||||
from litellm._logging import verbose_router_logger
|
||||
from litellm.caching.dual_cache import DualCache
|
||||
from litellm.router_strategy.complexity_router.complexity_router import (
|
||||
ComplexityRouter,
|
||||
DimensionScore,
|
||||
|
|
@ -130,9 +129,7 @@ class TestTokenScoring:
|
|||
tier, score, signals = complexity_router.classify("What is Python?")
|
||||
# Should be classified as SIMPLE due to short length and simple indicator
|
||||
assert tier == ComplexityTier.SIMPLE
|
||||
assert any("short" in s.lower() for s in signals) or any(
|
||||
"simple" in s.lower() for s in signals
|
||||
)
|
||||
assert any("short" in s.lower() for s in signals) or any("simple" in s.lower() for s in signals)
|
||||
|
||||
def test_long_prompt_positive_score(self, complexity_router):
|
||||
"""Long prompts should get positive scores (complex indicator)."""
|
||||
|
|
@ -143,9 +140,7 @@ class TestTokenScoring:
|
|||
tier, score, signals = complexity_router.classify(long_prompt)
|
||||
# Should have positive score and detect long token count or technical terms
|
||||
assert score > 0, f"Expected positive score for long prompt, got {score}"
|
||||
assert any("long" in s.lower() for s in signals) or any(
|
||||
"technical" in s.lower() for s in signals
|
||||
)
|
||||
assert any("long" in s.lower() for s in signals) or any("technical" in s.lower() for s in signals)
|
||||
|
||||
|
||||
class TestCodePresenceScoring:
|
||||
|
|
@ -220,9 +215,7 @@ class TestMultiStepPatterns:
|
|||
|
||||
def test_first_then_pattern(self, complexity_router):
|
||||
"""'First...then' patterns should increase complexity."""
|
||||
prompt = (
|
||||
"First analyze the data, then create a visualization, then write a report"
|
||||
)
|
||||
prompt = "First analyze the data, then create a visualization, then write a report"
|
||||
tier, score, signals = complexity_router.classify(prompt)
|
||||
assert any("multi-step" in s.lower() for s in signals)
|
||||
|
||||
|
|
@ -266,9 +259,7 @@ class TestTierAssignment:
|
|||
)
|
||||
tier, score, signals = complexity_router.classify(prompt)
|
||||
# Should detect technical terms
|
||||
assert any(
|
||||
"technical" in s.lower() for s in signals
|
||||
), f"Expected technical signals, got {signals}"
|
||||
assert any("technical" in s.lower() for s in signals), f"Expected technical signals, got {signals}"
|
||||
# Score should be positive due to technical content
|
||||
assert score > 0, f"Expected positive score, got {score}"
|
||||
|
||||
|
|
@ -468,13 +459,9 @@ class TestConfigOverrides:
|
|||
complexity_router_config=config,
|
||||
)
|
||||
# With very low thresholds, even neutral prompts should be COMPLEX or higher
|
||||
tier, score, signals = router.classify(
|
||||
"Explain how HTTP works with REST APIs and distributed systems"
|
||||
)
|
||||
tier, score, signals = router.classify("Explain how HTTP works with REST APIs and distributed systems")
|
||||
# With boundaries this low, should be at least MEDIUM (anything above -0.5)
|
||||
assert (
|
||||
tier != ComplexityTier.SIMPLE
|
||||
), f"Expected non-SIMPLE tier, got {tier} with score {score}"
|
||||
assert tier != ComplexityTier.SIMPLE, f"Expected non-SIMPLE tier, got {tier} with score {score}"
|
||||
|
||||
def test_custom_token_thresholds(self, mock_router_instance):
|
||||
"""Test custom token thresholds work correctly."""
|
||||
|
|
@ -499,9 +486,7 @@ class TestConfigOverrides:
|
|||
long_prompt = "This is a test prompt " * 30 # ~120 tokens
|
||||
tier, score, signals = router.classify(long_prompt)
|
||||
# Should get token length signal indicating "long"
|
||||
assert any(
|
||||
"long" in s.lower() if s else False for s in signals
|
||||
), f"Expected 'long' signal, got {signals}"
|
||||
assert any("long" in s.lower() if s else False for s in signals), f"Expected 'long' signal, got {signals}"
|
||||
|
||||
|
||||
class TestCustomTechnicalKeywords:
|
||||
|
|
@ -516,9 +501,7 @@ class TestCustomTechnicalKeywords:
|
|||
)
|
||||
assert router.technical_keywords == DEFAULT_TECHNICAL_KEYWORDS + ["udp", "kafka"]
|
||||
|
||||
def test_custom_keywords_appended_to_technical_keywords_override(
|
||||
self, mock_router_instance
|
||||
):
|
||||
def test_custom_keywords_appended_to_technical_keywords_override(self, mock_router_instance):
|
||||
"""Custom keywords should be appended to a technical_keywords override."""
|
||||
router = ComplexityRouter(
|
||||
model_name="test-router",
|
||||
|
|
@ -535,9 +518,7 @@ class TestCustomTechnicalKeywords:
|
|||
router = ComplexityRouter(
|
||||
model_name="test-router",
|
||||
litellm_router_instance=mock_router_instance,
|
||||
complexity_router_config={
|
||||
"custom_technical_keywords": ["TCP", "udp", "UDP", "kafka"]
|
||||
},
|
||||
complexity_router_config={"custom_technical_keywords": ["TCP", "udp", "UDP", "kafka"]},
|
||||
)
|
||||
lowered = [kw.lower() for kw in router.technical_keywords]
|
||||
assert lowered == [kw.lower() for kw in DEFAULT_TECHNICAL_KEYWORDS] + [
|
||||
|
|
@ -560,9 +541,7 @@ class TestCustomTechnicalKeywords:
|
|||
assert router_absent.technical_keywords == DEFAULT_TECHNICAL_KEYWORDS
|
||||
assert router_none.technical_keywords == DEFAULT_TECHNICAL_KEYWORDS
|
||||
|
||||
def test_prompt_with_only_custom_keywords_scores_technical(
|
||||
self, mock_router_instance, basic_config
|
||||
):
|
||||
def test_prompt_with_only_custom_keywords_scores_technical(self, mock_router_instance, basic_config):
|
||||
"""A prompt matching only custom keywords should score higher on technicalTerms."""
|
||||
prompt = "Configure udp multicast between kafka brokers"
|
||||
baseline_router = ComplexityRouter(
|
||||
|
|
@ -581,9 +560,7 @@ class TestCustomTechnicalKeywords:
|
|||
_, baseline_score, baseline_signals = baseline_router.classify(prompt)
|
||||
_, custom_score, custom_signals = custom_router.classify(prompt)
|
||||
assert not any("technical" in s.lower() for s in baseline_signals)
|
||||
assert any(
|
||||
"technical" in s.lower() for s in custom_signals
|
||||
), f"Expected technical signal, got {custom_signals}"
|
||||
assert any("technical" in s.lower() for s in custom_signals), f"Expected technical signal, got {custom_signals}"
|
||||
assert custom_score > baseline_score
|
||||
|
||||
|
||||
|
|
@ -776,9 +753,7 @@ class TestKeywordFalsePositives:
|
|||
prompt = "What is the capital of France?"
|
||||
tier, score, signals = complexity_router.classify(prompt)
|
||||
# Should NOT detect code presence from 'api' in 'capital'
|
||||
assert not any(
|
||||
"code" in s.lower() for s in signals
|
||||
), "False positive: got code signal from 'capital'"
|
||||
assert not any("code" in s.lower() for s in signals), "False positive: got code signal from 'capital'"
|
||||
# Should be SIMPLE (definition question)
|
||||
assert tier == ComplexityTier.SIMPLE
|
||||
|
||||
|
|
@ -787,9 +762,7 @@ class TestKeywordFalsePositives:
|
|||
prompt = "Explain digital marketing strategies"
|
||||
tier, score, signals = complexity_router.classify(prompt)
|
||||
# Should NOT detect code presence from 'git' in 'digital'
|
||||
assert not any(
|
||||
"code" in s.lower() for s in signals
|
||||
), "False positive: got code signal from 'digital'"
|
||||
assert not any("code" in s.lower() for s in signals), "False positive: got code signal from 'digital'"
|
||||
|
||||
def test_try_not_in_entry(self, complexity_router):
|
||||
"""'try' should not match in 'entry'."""
|
||||
|
|
@ -803,43 +776,33 @@ class TestKeywordFalsePositives:
|
|||
"""'error' should not match in 'terrorism'."""
|
||||
prompt = "The country is dealing with terrorism"
|
||||
tier, score, signals = complexity_router.classify(prompt)
|
||||
assert not any(
|
||||
"code" in s.lower() for s in signals
|
||||
), "False positive: got code signal from 'terrorism'"
|
||||
assert not any("code" in s.lower() for s in signals), "False positive: got code signal from 'terrorism'"
|
||||
|
||||
def test_class_not_in_classical(self, complexity_router):
|
||||
"""'class' should not match in 'classical'."""
|
||||
prompt = "I enjoy listening to classical music"
|
||||
tier, score, signals = complexity_router.classify(prompt)
|
||||
assert not any(
|
||||
"code" in s.lower() for s in signals
|
||||
), "False positive: got code signal from 'classical'"
|
||||
assert not any("code" in s.lower() for s in signals), "False positive: got code signal from 'classical'"
|
||||
|
||||
def test_merge_not_in_emerged(self, complexity_router):
|
||||
"""'merge' should not match in 'emerged'."""
|
||||
prompt = "A new leader emerged from the crowd"
|
||||
tier, score, signals = complexity_router.classify(prompt)
|
||||
assert not any(
|
||||
"code" in s.lower() for s in signals
|
||||
), "False positive: got code signal from 'emerged'"
|
||||
assert not any("code" in s.lower() for s in signals), "False positive: got code signal from 'emerged'"
|
||||
|
||||
def test_actual_api_keyword_detected(self, complexity_router):
|
||||
"""Actual 'api' usage should be detected."""
|
||||
prompt = "How do I call the REST api endpoint?"
|
||||
tier, score, signals = complexity_router.classify(prompt)
|
||||
# Should detect code presence from actual 'api' usage
|
||||
assert any(
|
||||
"code" in s.lower() for s in signals
|
||||
), f"Expected code signal for 'api', got {signals}"
|
||||
assert any("code" in s.lower() for s in signals), f"Expected code signal for 'api', got {signals}"
|
||||
|
||||
def test_actual_git_keyword_detected(self, complexity_router):
|
||||
"""Actual 'git' usage should be detected."""
|
||||
prompt = "How do I use git to commit changes?"
|
||||
tier, score, signals = complexity_router.classify(prompt)
|
||||
# Should detect code presence from actual 'git' usage
|
||||
assert any(
|
||||
"code" in s.lower() for s in signals
|
||||
), f"Expected code signal for 'git', got {signals}"
|
||||
assert any("code" in s.lower() for s in signals), f"Expected code signal for 'git', got {signals}"
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
|
|
@ -859,9 +822,7 @@ class TestEdgeCases:
|
|||
# Should have positive score due to length
|
||||
assert score > 0, f"Expected positive score for very long prompt, got {score}"
|
||||
# Should detect long token count
|
||||
assert any(
|
||||
"long" in s.lower() for s in signals
|
||||
), f"Expected 'long' signal, got {signals}"
|
||||
assert any("long" in s.lower() for s in signals), f"Expected 'long' signal, got {signals}"
|
||||
|
||||
def test_unicode_prompt(self, complexity_router):
|
||||
"""Test handling of unicode characters."""
|
||||
|
|
@ -879,9 +840,7 @@ class TestEdgeCases:
|
|||
"""
|
||||
tier, score, signals = complexity_router.classify(prompt)
|
||||
# The "step N" pattern should be detected
|
||||
assert any(
|
||||
"multi-step" in s.lower() for s in signals
|
||||
), f"Expected multi-step signal, got {signals}"
|
||||
assert any("multi-step" in s.lower() for s in signals), f"Expected multi-step signal, got {signals}"
|
||||
|
||||
|
||||
class TestRouterComplexityDeploymentMethods:
|
||||
|
|
@ -1019,9 +978,7 @@ class TestAsyncPreRoutingHookMultiFormat:
|
|||
assert result.messages is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_route_with_responses_api_string_input(
|
||||
self, complexity_router
|
||||
):
|
||||
async def test_should_route_with_responses_api_string_input(self, complexity_router):
|
||||
"""Test routing with Responses API string input via handler dispatch."""
|
||||
from litellm.llms.openai.responses.guardrail_translation.handler import (
|
||||
OpenAIResponsesHandler,
|
||||
|
|
@ -1109,9 +1066,7 @@ class TestAsyncPreRoutingHookMultiFormat:
|
|||
assert result.model is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_return_none_when_no_messages_or_input(
|
||||
self, complexity_router
|
||||
):
|
||||
async def test_should_return_none_when_no_messages_or_input(self, complexity_router):
|
||||
"""Test that None is returned when neither messages nor input is available."""
|
||||
result = await complexity_router.async_pre_routing_hook(
|
||||
model="test-model",
|
||||
|
|
@ -1122,9 +1077,7 @@ class TestAsyncPreRoutingHookMultiFormat:
|
|||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_prefer_original_messages_over_conversion(
|
||||
self, complexity_router
|
||||
):
|
||||
async def test_should_prefer_original_messages_over_conversion(self, complexity_router):
|
||||
"""Test that original messages are used when both messages and input are available."""
|
||||
messages = [{"role": "user", "content": "What is 2+2?"}]
|
||||
result = await complexity_router.async_pre_routing_hook(
|
||||
|
|
@ -1136,9 +1089,7 @@ class TestAsyncPreRoutingHookMultiFormat:
|
|||
assert result.messages == messages
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_include_instructions_in_classification(
|
||||
self, complexity_router
|
||||
):
|
||||
async def test_should_include_instructions_in_classification(self, complexity_router):
|
||||
"""Test that Responses API instructions influence classification via system message."""
|
||||
from litellm.llms.openai.responses.guardrail_translation.handler import (
|
||||
OpenAIResponsesHandler,
|
||||
|
|
@ -1175,9 +1126,7 @@ class TestExtractUserMessageAndSystemPrompt:
|
|||
{"role": "assistant", "content": "Hi!"},
|
||||
{"role": "user", "content": "How are you?"},
|
||||
]
|
||||
user_msg, sys_prompt = ComplexityRouter._extract_user_message_and_system_prompt(
|
||||
messages
|
||||
)
|
||||
user_msg, sys_prompt = ComplexityRouter._extract_user_message_and_system_prompt(messages)
|
||||
assert user_msg == "How are you?"
|
||||
assert sys_prompt == "You are helpful."
|
||||
|
||||
|
|
@ -1187,9 +1136,7 @@ class TestExtractUserMessageAndSystemPrompt:
|
|||
{"role": "system", "content": "You are helpful."},
|
||||
{"role": "assistant", "content": "Hi!"},
|
||||
]
|
||||
user_msg, sys_prompt = ComplexityRouter._extract_user_message_and_system_prompt(
|
||||
messages
|
||||
)
|
||||
user_msg, sys_prompt = ComplexityRouter._extract_user_message_and_system_prompt(messages)
|
||||
assert user_msg is None
|
||||
assert sys_prompt == "You are helpful."
|
||||
|
||||
|
|
@ -1207,17 +1154,13 @@ class TestExtractUserMessageAndSystemPrompt:
|
|||
],
|
||||
}
|
||||
]
|
||||
user_msg, sys_prompt = ComplexityRouter._extract_user_message_and_system_prompt(
|
||||
messages
|
||||
)
|
||||
user_msg, sys_prompt = ComplexityRouter._extract_user_message_and_system_prompt(messages)
|
||||
assert user_msg == "Describe this image"
|
||||
assert sys_prompt is None
|
||||
|
||||
def test_should_handle_empty_messages(self):
|
||||
"""Test with empty messages list."""
|
||||
user_msg, sys_prompt = ComplexityRouter._extract_user_message_and_system_prompt(
|
||||
[]
|
||||
)
|
||||
user_msg, sys_prompt = ComplexityRouter._extract_user_message_and_system_prompt([])
|
||||
assert user_msg is None
|
||||
assert sys_prompt is None
|
||||
|
||||
|
|
@ -1282,17 +1225,13 @@ class TestLLMClassifier:
|
|||
assert tier == ComplexityTier.SIMPLE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aclassify_llm_success_routes_by_llm_verdict(
|
||||
self, llm_complexity_router, mock_router_instance
|
||||
):
|
||||
async def test_aclassify_llm_success_routes_by_llm_verdict(self, llm_complexity_router, mock_router_instance):
|
||||
"""A well-formed structured LLM response should decide the tier directly.
|
||||
|
||||
Uses a prompt that heuristic scoring alone would classify as SIMPLE, to prove
|
||||
the LLM verdict -- not the heuristic scorer -- is what decided the tier.
|
||||
"""
|
||||
mock_router_instance.acompletion = AsyncMock(
|
||||
return_value=_llm_response('{"tier": "COMPLEX"}')
|
||||
)
|
||||
mock_router_instance.acompletion = AsyncMock(return_value=_llm_response('{"tier": "COMPLEX"}'))
|
||||
tier, score, signals = await llm_complexity_router.aclassify("hi")
|
||||
assert tier == ComplexityTier.COMPLEX
|
||||
assert "llm-classifier:COMPLEX" in signals
|
||||
|
|
@ -1311,13 +1250,9 @@ class TestLLMClassifier:
|
|||
sees no user_api_key/team_id/user_id and silently drops all spend logging
|
||||
and budget accounting for the classifier call.
|
||||
"""
|
||||
mock_router_instance.acompletion = AsyncMock(
|
||||
return_value=_llm_response('{"tier": "SIMPLE"}')
|
||||
)
|
||||
mock_router_instance.acompletion = AsyncMock(return_value=_llm_response('{"tier": "SIMPLE"}'))
|
||||
request_metadata = {"user_api_key": "sk-abc", "user_api_key_team_id": "team-1"}
|
||||
await llm_complexity_router.aclassify(
|
||||
"hi", request_kwargs={"litellm_metadata": request_metadata}
|
||||
)
|
||||
await llm_complexity_router.aclassify("hi", request_kwargs={"litellm_metadata": request_metadata})
|
||||
call_kwargs = mock_router_instance.acompletion.call_args.kwargs
|
||||
assert call_kwargs["metadata"] == request_metadata
|
||||
|
||||
|
|
@ -1333,18 +1268,14 @@ class TestLLMClassifier:
|
|||
business touching, so it must be stripped while the rest of the attribution
|
||||
metadata (key/team) is preserved.
|
||||
"""
|
||||
mock_router_instance.acompletion = AsyncMock(
|
||||
return_value=_llm_response('{"tier": "SIMPLE"}')
|
||||
)
|
||||
mock_router_instance.acompletion = AsyncMock(return_value=_llm_response('{"tier": "SIMPLE"}'))
|
||||
request_metadata = {
|
||||
"user_api_key": "sk-abc",
|
||||
"user_api_key_team_id": "team-1",
|
||||
"user_api_key_budget_reservation": {"reserved_cost": 1.0},
|
||||
"user_api_key_auth": {"models": ["gpt-4o"], "budget_reservation": {"reserved_cost": 1.0}},
|
||||
}
|
||||
await llm_complexity_router.aclassify(
|
||||
"hi", request_kwargs={"litellm_metadata": request_metadata}
|
||||
)
|
||||
await llm_complexity_router.aclassify("hi", request_kwargs={"litellm_metadata": request_metadata})
|
||||
call_kwargs = mock_router_instance.acompletion.call_args.kwargs
|
||||
# user_api_key_budget_reservation is stripped (budget enforcement) while
|
||||
# user_api_key_auth is kept so _filter_deployments_by_model_access_groups
|
||||
|
|
@ -1391,13 +1322,9 @@ class TestLLMClassifier:
|
|||
assert tier == ComplexityTier.SIMPLE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pre_routing_hook_uses_llm_classifier_end_to_end(
|
||||
self, llm_complexity_router, mock_router_instance
|
||||
):
|
||||
async def test_pre_routing_hook_uses_llm_classifier_end_to_end(self, llm_complexity_router, mock_router_instance):
|
||||
"""The full pre-routing hook should route using the LLM classifier's verdict."""
|
||||
mock_router_instance.acompletion = AsyncMock(
|
||||
return_value=_llm_response('{"tier": "REASONING"}')
|
||||
)
|
||||
mock_router_instance.acompletion = AsyncMock(return_value=_llm_response('{"tier": "REASONING"}'))
|
||||
request_metadata = {"user_api_key": "sk-abc", "user_api_key_team_id": "team-1"}
|
||||
result = await llm_complexity_router.async_pre_routing_hook(
|
||||
model="test-model",
|
||||
|
|
@ -1410,6 +1337,186 @@ class TestLLMClassifier:
|
|||
assert call_kwargs["metadata"] == request_metadata
|
||||
|
||||
|
||||
class TestRouterPreRoutingAliasOverrides:
|
||||
"""
|
||||
Regression tests for: litellm_params configured on a complexity-router alias
|
||||
entry (e.g. `cache_control_injection_points`, `drop_params`) were silently
|
||||
dropped, because `async_pre_routing_hook` swaps `model` from the alias name
|
||||
to the selected tier's model *before* the deployment lookup - so the actual
|
||||
outbound call only ever merges in the tier deployment's own litellm_params,
|
||||
never the alias's.
|
||||
"""
|
||||
|
||||
def _make_router(self) -> Router:
|
||||
return Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "smart-router",
|
||||
"litellm_params": {
|
||||
"model": "auto_router/complexity_router",
|
||||
"drop_params": True,
|
||||
"cache_control_injection_points": [{"location": "message", "role": "system"}],
|
||||
"complexity_router_config": {
|
||||
"tiers": {
|
||||
"SIMPLE": "gpt-4o-mini",
|
||||
"MEDIUM": "gpt-4o",
|
||||
}
|
||||
},
|
||||
"complexity_router_default_model": "gpt-4o",
|
||||
},
|
||||
},
|
||||
{
|
||||
"model_name": "gpt-4o-mini",
|
||||
"litellm_params": {"model": "openai/gpt-4o-mini"},
|
||||
},
|
||||
{
|
||||
"model_name": "gpt-4o",
|
||||
"litellm_params": {"model": "openai/gpt-4o"},
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alias_litellm_params_applied_to_request_kwargs(self):
|
||||
"""cache_control_injection_points/drop_params set on the alias entry
|
||||
reach the outbound request even though the tier deployment is what
|
||||
actually gets called."""
|
||||
router = self._make_router()
|
||||
request_kwargs: Dict = {}
|
||||
|
||||
result = await router.async_pre_routing_hook(
|
||||
model="smart-router",
|
||||
request_kwargs=request_kwargs,
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert request_kwargs["drop_params"] is True
|
||||
assert request_kwargs["cache_control_injection_points"] == [{"location": "message", "role": "system"}]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alias_overrides_exclude_only_model(self):
|
||||
"""`model` (the alias marker, e.g. auto_router/complexity_router) is
|
||||
excluded since it's never a real provider model. Router-only fields
|
||||
like complexity_router_config DO flow through into request_kwargs at
|
||||
this layer - they're filtered from the actual outbound LLM call
|
||||
downstream by litellm.types.utils.all_litellm_params instead, not by
|
||||
the router's pre-routing hook. See test_router_init_only_params_are_
|
||||
never_sent_to_a_provider for the guard on that downstream filter."""
|
||||
router = self._make_router()
|
||||
request_kwargs: Dict = {}
|
||||
|
||||
await router.async_pre_routing_hook(
|
||||
model="smart-router",
|
||||
request_kwargs=request_kwargs,
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
)
|
||||
|
||||
assert "model" not in request_kwargs
|
||||
assert request_kwargs["complexity_router_config"] == {
|
||||
"tiers": {
|
||||
"SIMPLE": "gpt-4o-mini",
|
||||
"MEDIUM": "gpt-4o",
|
||||
}
|
||||
}
|
||||
assert request_kwargs["complexity_router_default_model"] == "gpt-4o"
|
||||
|
||||
def test_router_init_only_params_are_never_sent_to_a_provider(self):
|
||||
"""The router's pre-routing hook only excludes `model` (see
|
||||
test_alias_overrides_exclude_only_model above) - every other alias
|
||||
litellm_param, including router-init-only fields like
|
||||
complexity_router_config, flows into request_kwargs unfiltered. That's
|
||||
only safe because litellm.completion()/acompletion() itself strips
|
||||
anything listed in all_litellm_params before building the provider
|
||||
request. If one of these keys is ever removed from that list, it
|
||||
ships raw to the real provider as extra_body - verified live via
|
||||
litellm.completion(..., complexity_router_config={...}) landing in
|
||||
extra_body before this list included it."""
|
||||
from litellm.types.utils import all_litellm_params
|
||||
|
||||
router_init_only_params = (
|
||||
"auto_router_config_path",
|
||||
"auto_router_config",
|
||||
"auto_router_default_model",
|
||||
"auto_router_embedding_model",
|
||||
"complexity_router_config",
|
||||
"complexity_router_default_model",
|
||||
"adaptive_router_config",
|
||||
"adaptive_router_default_model",
|
||||
"quality_router_config",
|
||||
"quality_router_default_model",
|
||||
)
|
||||
for param in router_init_only_params:
|
||||
assert param in all_litellm_params, (
|
||||
f"{param} must stay in litellm.types.utils.all_litellm_params - "
|
||||
"removing it means it ships raw to the real provider as extra_body"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_caller_supplied_kwargs_are_not_overwritten(self):
|
||||
"""A value the caller already passed for this request takes
|
||||
precedence over the alias's configured default."""
|
||||
router = self._make_router()
|
||||
request_kwargs: Dict = {"drop_params": False}
|
||||
|
||||
await router.async_pre_routing_hook(
|
||||
model="smart-router",
|
||||
request_kwargs=request_kwargs,
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
)
|
||||
|
||||
assert request_kwargs["drop_params"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_alias_model_is_untouched(self):
|
||||
"""A plain (non-router-alias) model name is not affected by the
|
||||
alias-override merge at all."""
|
||||
router = self._make_router()
|
||||
request_kwargs: Dict = {}
|
||||
|
||||
result = await router.async_pre_routing_hook(
|
||||
model="gpt-4o-mini",
|
||||
request_kwargs=request_kwargs,
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
)
|
||||
|
||||
assert result is None
|
||||
assert request_kwargs == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adaptive_router_alias_overrides_survive_reload(self):
|
||||
"""Alias litellm_params are read fresh from self.model_list at request
|
||||
time (not cached at init), so a set_model_list() reload (e.g.
|
||||
/config/reload) - which rebuilds self.model_list but leaves an
|
||||
already-built AdaptiveRouter alone - can't leave them stale."""
|
||||
model_list = [
|
||||
{
|
||||
"model_name": "smart-router",
|
||||
"litellm_params": {
|
||||
"model": "auto_router/adaptive_router",
|
||||
"drop_params": True,
|
||||
"adaptive_router_config": {"available_models": ["gpt-4o-mini"]},
|
||||
},
|
||||
},
|
||||
{
|
||||
"model_name": "gpt-4o-mini",
|
||||
"litellm_params": {"model": "openai/gpt-4o-mini"},
|
||||
},
|
||||
]
|
||||
router = Router(model_list=model_list)
|
||||
router.set_model_list(model_list)
|
||||
assert "smart-router" in router.adaptive_routers
|
||||
|
||||
request_kwargs: Dict = {}
|
||||
await router.async_pre_routing_hook(
|
||||
model="smart-router",
|
||||
request_kwargs=request_kwargs,
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
)
|
||||
|
||||
assert request_kwargs["drop_params"] is True
|
||||
|
||||
|
||||
class TestAdaptiveSoftFloors:
|
||||
def test_adaptive_defaults_use_cost_weighted_cold_policy(self):
|
||||
config = ComplexityRouterConfig(
|
||||
|
|
@ -1430,9 +1537,7 @@ class TestAdaptiveSoftFloors:
|
|||
"model": "openai/gpt-4o-mini",
|
||||
"input_cost_per_token": 0.00000015,
|
||||
},
|
||||
"model_info": {
|
||||
"adaptive_router_preferences": {"quality_tier": 1, "strengths": []}
|
||||
},
|
||||
"model_info": {"adaptive_router_preferences": {"quality_tier": 1, "strengths": []}},
|
||||
},
|
||||
{
|
||||
"model_name": "premium",
|
||||
|
|
@ -1440,9 +1545,7 @@ class TestAdaptiveSoftFloors:
|
|||
"model": "openai/gpt-4o",
|
||||
"input_cost_per_token": 0.000005,
|
||||
},
|
||||
"model_info": {
|
||||
"adaptive_router_preferences": {"quality_tier": 3, "strengths": []}
|
||||
},
|
||||
"model_info": {"adaptive_router_preferences": {"quality_tier": 3, "strengths": []}},
|
||||
},
|
||||
]
|
||||
router.model_name_to_deployment_indices = {"cheap": [0], "premium": [1]}
|
||||
|
|
@ -1467,9 +1570,7 @@ class TestAdaptiveSoftFloors:
|
|||
with pytest.raises(ValidationError):
|
||||
ComplexityRouterConfig(adaptive=True, tiers={"SIMPLE": []})
|
||||
|
||||
def test_cold_start_randomly_samples_unobserved_classified_tier_models(
|
||||
self, adaptive_router_instance
|
||||
):
|
||||
def test_cold_start_randomly_samples_unobserved_classified_tier_models(self, adaptive_router_instance):
|
||||
cr = ComplexityRouter(
|
||||
model_name="hybrid",
|
||||
litellm_router_instance=adaptive_router_instance,
|
||||
|
|
@ -1498,9 +1599,7 @@ class TestAdaptiveSoftFloors:
|
|||
"premium",
|
||||
}
|
||||
|
||||
def test_get_model_for_tier_list_without_adaptive_random_choice(
|
||||
self, mock_router_instance
|
||||
):
|
||||
def test_get_model_for_tier_list_without_adaptive_random_choice(self, mock_router_instance):
|
||||
router = ComplexityRouter(
|
||||
model_name="test",
|
||||
litellm_router_instance=mock_router_instance,
|
||||
|
|
@ -1519,9 +1618,7 @@ class TestAdaptiveSoftFloors:
|
|||
choice.assert_called_once_with(pool)
|
||||
assert router.get_model_for_tier(ComplexityTier.MEDIUM) == "mid"
|
||||
|
||||
def test_soft_floor_prefers_home_tier_when_posteriors_equal(
|
||||
self, adaptive_router_instance, hybrid_config
|
||||
):
|
||||
def test_soft_floor_prefers_home_tier_when_posteriors_equal(self, adaptive_router_instance, hybrid_config):
|
||||
from litellm.router_strategy.adaptive_router.bandit import BanditCell
|
||||
from litellm.types.router import RequestType
|
||||
|
||||
|
|
@ -1533,9 +1630,7 @@ class TestAdaptiveSoftFloors:
|
|||
adaptive = cr._ensure_adaptive_router()
|
||||
assert adaptive is not None
|
||||
for model in ("cheap", "premium"):
|
||||
adaptive._cells[(RequestType.GENERAL, model)] = BanditCell(
|
||||
alpha=5.0, beta=5.0
|
||||
)
|
||||
adaptive._cells[(RequestType.GENERAL, model)] = BanditCell(alpha=5.0, beta=5.0)
|
||||
|
||||
# Equal quality samples; home-tier penalty should favor cheap for SIMPLE.
|
||||
with patch(
|
||||
|
|
@ -1545,9 +1640,7 @@ class TestAdaptiveSoftFloors:
|
|||
picked = cr._soft_floor_pick(ComplexityTier.SIMPLE, "hi")
|
||||
assert picked == "cheap"
|
||||
|
||||
def test_soft_floor_allows_cross_tier_when_posterior_dominates(
|
||||
self, adaptive_router_instance, hybrid_config
|
||||
):
|
||||
def test_soft_floor_allows_cross_tier_when_posterior_dominates(self, adaptive_router_instance, hybrid_config):
|
||||
from litellm.router_strategy.adaptive_router.bandit import BanditCell
|
||||
from litellm.types.router import RequestType
|
||||
|
||||
|
|
@ -1558,12 +1651,8 @@ class TestAdaptiveSoftFloors:
|
|||
)
|
||||
adaptive = cr._ensure_adaptive_router()
|
||||
assert adaptive is not None
|
||||
adaptive._cells[(RequestType.GENERAL, "cheap")] = BanditCell(
|
||||
alpha=1.0, beta=20.0
|
||||
)
|
||||
adaptive._cells[(RequestType.GENERAL, "premium")] = BanditCell(
|
||||
alpha=20.0, beta=1.0
|
||||
)
|
||||
adaptive._cells[(RequestType.GENERAL, "cheap")] = BanditCell(alpha=1.0, beta=20.0)
|
||||
adaptive._cells[(RequestType.GENERAL, "premium")] = BanditCell(alpha=20.0, beta=1.0)
|
||||
|
||||
with patch(
|
||||
"litellm.router_strategy.adaptive_router.bandit.thompson_sample",
|
||||
|
|
@ -1572,9 +1661,7 @@ class TestAdaptiveSoftFloors:
|
|||
picked = cr._soft_floor_pick(ComplexityTier.SIMPLE, "hi")
|
||||
assert picked == "premium"
|
||||
|
||||
def test_reused_model_has_zero_distance_in_each_configured_tier(
|
||||
self, adaptive_router_instance
|
||||
):
|
||||
def test_reused_model_has_zero_distance_in_each_configured_tier(self, adaptive_router_instance):
|
||||
from litellm.router_strategy.adaptive_router.bandit import BanditCell
|
||||
from litellm.types.router import RequestType
|
||||
|
||||
|
|
@ -1593,9 +1680,7 @@ class TestAdaptiveSoftFloors:
|
|||
adaptive = cr._ensure_adaptive_router()
|
||||
assert adaptive is not None
|
||||
for model in ("cheap", "premium"):
|
||||
adaptive._cells[(RequestType.GENERAL, model)] = BanditCell(
|
||||
alpha=6.0, beta=5.0
|
||||
)
|
||||
adaptive._cells[(RequestType.GENERAL, model)] = BanditCell(alpha=6.0, beta=5.0)
|
||||
request_kwargs: Dict = {"metadata": {}}
|
||||
|
||||
with patch(
|
||||
|
|
@ -1604,20 +1689,14 @@ class TestAdaptiveSoftFloors:
|
|||
):
|
||||
cr._soft_floor_pick(ComplexityTier.MEDIUM, "hi", request_kwargs)
|
||||
|
||||
candidates = request_kwargs["metadata"]["adaptive_router_decision"][
|
||||
"candidates"
|
||||
]
|
||||
assert {
|
||||
candidate["model"]: candidate["tier_distance"] for candidate in candidates
|
||||
} == {
|
||||
candidates = request_kwargs["metadata"]["adaptive_router_decision"]["candidates"]
|
||||
assert {candidate["model"]: candidate["tier_distance"] for candidate in candidates} == {
|
||||
"cheap": 0,
|
||||
"premium": 0,
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pre_routing_hook_adaptive_stashes_chosen_model(
|
||||
self, adaptive_router_instance, hybrid_config
|
||||
):
|
||||
async def test_pre_routing_hook_adaptive_stashes_chosen_model(self, adaptive_router_instance, hybrid_config):
|
||||
cr = ComplexityRouter(
|
||||
model_name="hybrid",
|
||||
litellm_router_instance=adaptive_router_instance,
|
||||
|
|
@ -1631,10 +1710,7 @@ class TestAdaptiveSoftFloors:
|
|||
)
|
||||
assert result is not None
|
||||
assert result.model in {"cheap", "premium"}
|
||||
assert (
|
||||
request_kwargs["metadata"].get("adaptive_router_chosen_model")
|
||||
== result.model
|
||||
)
|
||||
assert request_kwargs["metadata"].get("adaptive_router_chosen_model") == result.model
|
||||
decision = request_kwargs["metadata"]["adaptive_router_decision"]
|
||||
assert decision["phase"] == "cold_start"
|
||||
assert decision["classified_tier"] == "SIMPLE"
|
||||
|
|
@ -1657,9 +1733,7 @@ class TestLexicalKeywordTierRules:
|
|||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matching_rule_overrides_scoring(
|
||||
self, mock_router_instance, rule_config
|
||||
):
|
||||
async def test_matching_rule_overrides_scoring(self, mock_router_instance, rule_config):
|
||||
"""A prompt hitting a rule keyword routes to that tier, not the scored tier."""
|
||||
router = ComplexityRouter(
|
||||
model_name="test-router",
|
||||
|
|
@ -1745,9 +1819,7 @@ class TestLexicalKeywordTierRules:
|
|||
assert router._lexical_tier_override("nothing relevant here") is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_rule_match_falls_back_to_scoring(
|
||||
self, mock_router_instance, basic_config
|
||||
):
|
||||
async def test_no_rule_match_falls_back_to_scoring(self, mock_router_instance, basic_config):
|
||||
"""A prompt that matches no rule is classified by the scorer as usual."""
|
||||
config = {
|
||||
**basic_config,
|
||||
|
|
@ -1768,9 +1840,7 @@ class TestLexicalKeywordTierRules:
|
|||
assert result is not None
|
||||
assert result.model == "gpt-4o-mini" # SIMPLE via scoring, rule did not fire
|
||||
|
||||
def test_word_boundary_avoids_substring_false_positive(
|
||||
self, mock_router_instance, basic_config
|
||||
):
|
||||
def test_word_boundary_avoids_substring_false_positive(self, mock_router_instance, basic_config):
|
||||
"""A single-word rule keyword must not match inside a larger word."""
|
||||
config = {
|
||||
**basic_config,
|
||||
|
|
@ -1788,10 +1858,7 @@ class TestLexicalKeywordTierRules:
|
|||
def _make_embedding_response(vectors: List[List[float]]) -> "litellm.EmbeddingResponse":
|
||||
return litellm.EmbeddingResponse(
|
||||
model="fake-embed",
|
||||
data=[
|
||||
{"embedding": vec, "index": idx, "object": "embedding"}
|
||||
for idx, vec in enumerate(vectors)
|
||||
],
|
||||
data=[{"embedding": vec, "index": idx, "object": "embedding"} for idx, vec in enumerate(vectors)],
|
||||
object="list",
|
||||
)
|
||||
|
||||
|
|
@ -1818,8 +1885,7 @@ class FakeEmbeddingRouter:
|
|||
|
||||
def _vectors(self, docs: List[str]) -> List[List[float]]:
|
||||
return [
|
||||
[1.0, 0.0] if any(marker in doc.lower() for marker in self._CLUSTER_MARKERS) else [0.0, 1.0]
|
||||
for doc in docs
|
||||
[1.0, 0.0] if any(marker in doc.lower() for marker in self._CLUSTER_MARKERS) else [0.0, 1.0] for doc in docs
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -2358,9 +2424,7 @@ class TestRoutingDecisionCauseLogging:
|
|||
verbose_router_logger.removeHandler(caplog.handler)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_literal_keyword_match_logs_its_cause(
|
||||
self, mock_router_instance, basic_config, router_log_capture
|
||||
):
|
||||
async def test_literal_keyword_match_logs_its_cause(self, mock_router_instance, basic_config, router_log_capture):
|
||||
config = {
|
||||
**basic_config,
|
||||
"keyword_tier_rules": [{"keywords": ["deploy to k8s"], "tier": "REASONING"}],
|
||||
|
|
@ -2406,9 +2470,7 @@ class TestRoutingDecisionCauseLogging:
|
|||
assert "cause=literal_keyword_match" not in router_log_capture.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complexity_scorer_logs_its_cause(
|
||||
self, mock_router_instance, basic_config, router_log_capture
|
||||
):
|
||||
async def test_complexity_scorer_logs_its_cause(self, mock_router_instance, basic_config, router_log_capture):
|
||||
# No keyword rules -> the scorer decides, and its line must be tagged as such.
|
||||
router = ComplexityRouter(
|
||||
model_name="test-router",
|
||||
|
|
@ -2424,3 +2486,217 @@ class TestRoutingDecisionCauseLogging:
|
|||
assert "score=" in router_log_capture.text
|
||||
assert "cause=literal_keyword_match" not in router_log_capture.text
|
||||
assert "cause=semantic_keyword_match" not in router_log_capture.text
|
||||
|
||||
|
||||
class TestSessionAffinity:
|
||||
"""Test the opt-in session_affinity sticky-routing behavior."""
|
||||
|
||||
REASONING_MESSAGE = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Let's think step by step and reason through this problem carefully.",
|
||||
}
|
||||
]
|
||||
SIMPLE_MESSAGE = [{"role": "user", "content": "Hello!"}]
|
||||
|
||||
@pytest.fixture
|
||||
def session_affinity_config(self, basic_config) -> Dict:
|
||||
return {**basic_config, "session_affinity": True}
|
||||
|
||||
@staticmethod
|
||||
def _request_kwargs(session_id: str) -> Dict:
|
||||
return {"metadata": {"session_id": session_id}}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disabled_by_default_reclassifies_every_turn(self, mock_router_instance, basic_config):
|
||||
"""Regression: session_affinity defaults to False, so a shared session_id must
|
||||
not pin the model -- each turn is still classified independently."""
|
||||
mock_router_instance.cache = DualCache()
|
||||
router = ComplexityRouter(
|
||||
model_name="test-router",
|
||||
litellm_router_instance=mock_router_instance,
|
||||
complexity_router_config=basic_config,
|
||||
)
|
||||
request_kwargs = self._request_kwargs("session-1")
|
||||
first = await router.async_pre_routing_hook(
|
||||
model="test-model", request_kwargs=request_kwargs, messages=self.REASONING_MESSAGE
|
||||
)
|
||||
second = await router.async_pre_routing_hook(
|
||||
model="test-model", request_kwargs=request_kwargs, messages=self.SIMPLE_MESSAGE
|
||||
)
|
||||
assert first.model == "o1-preview"
|
||||
assert second.model == "gpt-4o-mini"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pins_model_after_first_turn(self, mock_router_instance, session_affinity_config):
|
||||
mock_router_instance.cache = DualCache()
|
||||
router = ComplexityRouter(
|
||||
model_name="test-router",
|
||||
litellm_router_instance=mock_router_instance,
|
||||
complexity_router_config=session_affinity_config,
|
||||
)
|
||||
request_kwargs = self._request_kwargs("session-1")
|
||||
first = await router.async_pre_routing_hook(
|
||||
model="test-model", request_kwargs=request_kwargs, messages=self.REASONING_MESSAGE
|
||||
)
|
||||
assert first.model == "o1-preview"
|
||||
|
||||
with patch.object(router, "aclassify", wraps=router.aclassify) as spy_aclassify:
|
||||
second = await router.async_pre_routing_hook(
|
||||
model="test-model", request_kwargs=request_kwargs, messages=self.SIMPLE_MESSAGE
|
||||
)
|
||||
spy_aclassify.assert_not_called()
|
||||
# Pinned to the first turn's model, not re-classified down to SIMPLE.
|
||||
assert second.model == "o1-preview"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_sessions_classify_independently(self, mock_router_instance, session_affinity_config):
|
||||
mock_router_instance.cache = DualCache()
|
||||
router = ComplexityRouter(
|
||||
model_name="test-router",
|
||||
litellm_router_instance=mock_router_instance,
|
||||
complexity_router_config=session_affinity_config,
|
||||
)
|
||||
reasoning = await router.async_pre_routing_hook(
|
||||
model="test-model", request_kwargs=self._request_kwargs("session-a"), messages=self.REASONING_MESSAGE
|
||||
)
|
||||
simple = await router.async_pre_routing_hook(
|
||||
model="test-model", request_kwargs=self._request_kwargs("session-b"), messages=self.SIMPLE_MESSAGE
|
||||
)
|
||||
assert reasoning.model == "o1-preview"
|
||||
assert simple.model == "gpt-4o-mini"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_respects_ttl_seconds(self, mock_router_instance, basic_config):
|
||||
cache = AsyncMock()
|
||||
cache.async_get_cache = AsyncMock(return_value=None)
|
||||
mock_router_instance.cache = cache
|
||||
router = ComplexityRouter(
|
||||
model_name="test-router",
|
||||
litellm_router_instance=mock_router_instance,
|
||||
complexity_router_config={
|
||||
**basic_config,
|
||||
"session_affinity": True,
|
||||
"session_affinity_ttl_seconds": 120,
|
||||
},
|
||||
)
|
||||
await router.async_pre_routing_hook(
|
||||
model="test-model", request_kwargs=self._request_kwargs("session-1"), messages=self.SIMPLE_MESSAGE
|
||||
)
|
||||
cache.async_set_cache.assert_called_once()
|
||||
call_kwargs = cache.async_set_cache.call_args.kwargs
|
||||
assert call_kwargs["ttl"] == 120
|
||||
assert call_kwargs["value"] == "gpt-4o-mini"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ttl_refreshed_on_cache_hit(self, mock_router_instance, basic_config):
|
||||
"""Regression: a pinned turn must refresh the TTL, not just the first write --
|
||||
otherwise a session outliving session_affinity_ttl_seconds silently loses its pin."""
|
||||
cache = AsyncMock()
|
||||
cache.async_get_cache = AsyncMock(return_value="o1-preview")
|
||||
mock_router_instance.cache = cache
|
||||
router = ComplexityRouter(
|
||||
model_name="test-router",
|
||||
litellm_router_instance=mock_router_instance,
|
||||
complexity_router_config={
|
||||
**basic_config,
|
||||
"session_affinity": True,
|
||||
"session_affinity_ttl_seconds": 90,
|
||||
},
|
||||
)
|
||||
result = await router.async_pre_routing_hook(
|
||||
model="test-model", request_kwargs=self._request_kwargs("session-1"), messages=self.SIMPLE_MESSAGE
|
||||
)
|
||||
assert result.model == "o1-preview"
|
||||
cache.async_set_cache.assert_called_once()
|
||||
call_kwargs = cache.async_set_cache.call_args.kwargs
|
||||
assert call_kwargs["value"] == "o1-preview"
|
||||
assert call_kwargs["ttl"] == 90
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_api_keys_do_not_share_pin(self, mock_router_instance, session_affinity_config):
|
||||
"""A session_id is client-supplied and unauthenticated; two different callers
|
||||
(API keys) reusing the same session_id must not poison each other's pin."""
|
||||
mock_router_instance.cache = DualCache()
|
||||
router = ComplexityRouter(
|
||||
model_name="test-router",
|
||||
litellm_router_instance=mock_router_instance,
|
||||
complexity_router_config=session_affinity_config,
|
||||
)
|
||||
caller_a_kwargs = {"metadata": {"session_id": "shared-session", "user_api_key_hash": "key-a"}}
|
||||
caller_b_kwargs = {"metadata": {"session_id": "shared-session", "user_api_key_hash": "key-b"}}
|
||||
|
||||
pinned_for_a = await router.async_pre_routing_hook(
|
||||
model="test-model", request_kwargs=caller_a_kwargs, messages=self.REASONING_MESSAGE
|
||||
)
|
||||
assert pinned_for_a.model == "o1-preview"
|
||||
|
||||
# Caller B reuses the same session_id but has a different API key; its trivial
|
||||
# message must classify fresh, not inherit caller A's REASONING-tier pin.
|
||||
result_for_b = await router.async_pre_routing_hook(
|
||||
model="test-model", request_kwargs=caller_b_kwargs, messages=self.SIMPLE_MESSAGE
|
||||
)
|
||||
assert result_for_b.model == "gpt-4o-mini"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_session_id_falls_back_to_reclassify(self, mock_router_instance, session_affinity_config):
|
||||
cache = AsyncMock()
|
||||
mock_router_instance.cache = cache
|
||||
router = ComplexityRouter(
|
||||
model_name="test-router",
|
||||
litellm_router_instance=mock_router_instance,
|
||||
complexity_router_config=session_affinity_config,
|
||||
)
|
||||
result = await router.async_pre_routing_hook(
|
||||
model="test-model", request_kwargs={}, messages=self.SIMPLE_MESSAGE
|
||||
)
|
||||
assert result.model == "gpt-4o-mini"
|
||||
cache.async_get_cache.assert_not_called()
|
||||
cache.async_set_cache.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adaptive_pinned_turn_still_stamps_chosen_model_metadata(self, mock_router_instance):
|
||||
"""Regression: skipping classification on a pinned turn must not break the
|
||||
adaptive bandit's reward-feedback loop, which only records a turn's outcome
|
||||
when ADAPTIVE_ROUTER_CHOSEN_MODEL_KEY is present in the request metadata."""
|
||||
mock_router_instance.cache = DualCache()
|
||||
mock_router_instance.model_list = [
|
||||
{
|
||||
"model_name": "cheap",
|
||||
"litellm_params": {"model": "openai/gpt-4o-mini", "input_cost_per_token": 0.0},
|
||||
"model_info": {},
|
||||
},
|
||||
]
|
||||
mock_router_instance.model_name_to_deployment_indices = {"cheap": [0]}
|
||||
router = ComplexityRouter(
|
||||
model_name="hybrid",
|
||||
litellm_router_instance=mock_router_instance,
|
||||
complexity_router_config={
|
||||
"adaptive": True,
|
||||
"session_affinity": True,
|
||||
"tiers": {
|
||||
"SIMPLE": ["cheap"],
|
||||
"MEDIUM": ["cheap"],
|
||||
"COMPLEX": ["cheap"],
|
||||
"REASONING": ["cheap"],
|
||||
},
|
||||
"default_model": "cheap",
|
||||
},
|
||||
)
|
||||
first = await router.async_pre_routing_hook(
|
||||
model="hybrid",
|
||||
request_kwargs=self._request_kwargs("session-1"),
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
)
|
||||
assert first.model == "cheap"
|
||||
|
||||
request_kwargs_2 = self._request_kwargs("session-1")
|
||||
with patch.object(router, "aclassify", wraps=router.aclassify) as spy_aclassify:
|
||||
second = await router.async_pre_routing_hook(
|
||||
model="hybrid",
|
||||
request_kwargs=request_kwargs_2,
|
||||
messages=[{"role": "user", "content": "hi again"}],
|
||||
)
|
||||
spy_aclassify.assert_not_called()
|
||||
assert second.model == "cheap"
|
||||
assert request_kwargs_2["metadata"]["adaptive_router_chosen_model"] == "cheap"
|
||||
|
|
|
|||
|
|
@ -2081,3 +2081,79 @@ def test_stream_chunk_builder_text_completion_combines_text_and_usage():
|
|||
assert response.usage.prompt_tokens > 0
|
||||
assert response.usage.completion_tokens > 0
|
||||
assert response.usage.total_tokens == response.usage.prompt_tokens + response.usage.completion_tokens
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"aws_credential_kwargs",
|
||||
[
|
||||
{
|
||||
"aws_session_name": "litellm-gcp",
|
||||
"aws_role_name": "arn:aws:iam::123456789012:role/litellm-bedrock-role",
|
||||
"aws_web_identity_token": "oidc/google/108963886734710037768",
|
||||
},
|
||||
{
|
||||
"aws_access_key_id": "AKIASTATICKEYFORTEST",
|
||||
"aws_secret_access_key": "static-secret-key",
|
||||
"aws_session_token": "static-session-token",
|
||||
},
|
||||
],
|
||||
ids=["web_identity", "static_keys"],
|
||||
)
|
||||
async def test_acompletion_forwards_aws_credentials_through_responses_bridge(
|
||||
respx_mock: respx.MockRouter, monkeypatch, aws_credential_kwargs: dict
|
||||
):
|
||||
from botocore.credentials import Credentials
|
||||
|
||||
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
|
||||
|
||||
original_disable_aiohttp = litellm.disable_aiohttp_transport
|
||||
try:
|
||||
litellm.disable_aiohttp_transport = True
|
||||
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
|
||||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False)
|
||||
monkeypatch.delenv("BEDROCK_MANTLE_API_KEY", raising=False)
|
||||
|
||||
get_credentials_mock = MagicMock(return_value=Credentials("fake-key", "fake-secret"))
|
||||
monkeypatch.setattr(BaseAWSLLM, "get_credentials", get_credentials_mock)
|
||||
|
||||
respx_mock.post("https://bedrock-mantle.us-east-2.api.aws/openai/v1/responses").respond(
|
||||
json={
|
||||
"id": "resp_123",
|
||||
"object": "response",
|
||||
"created_at": 1760144904,
|
||||
"status": "completed",
|
||||
"model": "openai.gpt-5.4",
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
"id": "msg_1",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": [{"type": "output_text", "text": "ok", "annotations": []}],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
response = await litellm.acompletion(
|
||||
model="bedrock_mantle/openai.gpt-5.4",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
api_base="https://bedrock-mantle.us-east-2.api.aws/v1",
|
||||
aws_region_name="us-east-2",
|
||||
num_retries=0,
|
||||
**aws_credential_kwargs,
|
||||
)
|
||||
|
||||
assert response.choices[0].message.content == "ok"
|
||||
credential_kwargs = get_credentials_mock.call_args.kwargs
|
||||
assert credential_kwargs["aws_region_name"] == "us-east-2"
|
||||
for key, value in aws_credential_kwargs.items():
|
||||
assert credential_kwargs[key] == value
|
||||
authorization = respx_mock.calls.last.request.headers["Authorization"]
|
||||
assert authorization.startswith("AWS4-HMAC-SHA256")
|
||||
assert "fake-key" in authorization
|
||||
finally:
|
||||
litellm.disable_aiohttp_transport = original_disable_aiohttp
|
||||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
|
|
|
|||
|
|
@ -96,7 +96,9 @@ test.describe("Proxy Admin - Teams", () => {
|
|||
|
||||
const teamRow = page.locator("tr", { hasText: E2E_TEAM_DELETE_ALIAS }).first();
|
||||
await expect(teamRow).toBeVisible({ timeout: 10_000 });
|
||||
await teamRow.locator("svg, img").last().click();
|
||||
// Actions live in a kebab menu: open it, then click "Delete team".
|
||||
await teamRow.locator('[data-testid^="team-actions-"]').click();
|
||||
await page.getByTestId("team-action-delete").click();
|
||||
|
||||
const modal = page.locator(".ant-modal:visible");
|
||||
await expect(modal).toBeVisible({ timeout: 5_000 });
|
||||
|
|
|
|||
|
|
@ -515,6 +515,152 @@
|
|||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/MCPLogoSelector.test.tsx": {
|
||||
"unused-imports/no-unused-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.tsx": {
|
||||
"react-hooks/immutability": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/MCPSubmissionsTab.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
},
|
||||
"unused-imports/no-unused-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/ToolTestPanel.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 3
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/UserEnvVarsModal.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/create_mcp_server.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/mcp_connect.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/static-components": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/mcp_connection_status.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 3
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/mcp_discovery.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_config.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/immutability": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/mcp_server_view.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/mcp_tool_configuration.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/mcp_tools.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/memory/_components/MemoryView.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
|
|
@ -1497,13 +1643,13 @@
|
|||
},
|
||||
"src/components/Teams.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 4
|
||||
"count": 2
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 4
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/components/ToolDetail.tsx": {
|
||||
|
|
@ -1544,14 +1690,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/VirtualKeysPage/VirtualKeysTable.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 2
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/activity_metrics.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
|
|
@ -1796,11 +1934,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/common_components/user_search_modal.tsx": {
|
||||
"react-hooks/use-memo": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/constants.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
|
|
@ -1860,45 +1993,11 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/mcp_tools/ByokCredentialModal.tsx": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/MCPLogoSelector.test.tsx": {
|
||||
"unused-imports/no-unused-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.tsx": {
|
||||
"react-hooks/immutability": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/MCPSubmissionsTab.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/mcp_tools/MCPToolArgumentsForm.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
},
|
||||
"unused-imports/no-unused-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/mcp_tools/McpCrudPermissionPanel.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 3
|
||||
|
|
@ -1907,123 +2006,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/ToolTestPanel.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 3
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/UserEnvVarsModal.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/create_mcp_server.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/mcp_connect.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/static-components": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/mcp_connection_status.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 3
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/mcp_discovery.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_config.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/immutability": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/mcp_server_view.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/mcp_tool_configuration.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/mcp_tools.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/model_add/AddCredentialModal.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
|
@ -2117,9 +2099,6 @@
|
|||
"src/components/molecules/filter.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 2
|
||||
},
|
||||
"react-hooks/use-memo": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/molecules/models/columns.test.tsx": {
|
||||
|
|
@ -2181,9 +2160,6 @@
|
|||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 4
|
||||
},
|
||||
"react-hooks/use-memo": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/organization/organization_view.tsx": {
|
||||
|
|
@ -2545,4 +2521,4 @@
|
|||
"count": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
75
ui/litellm-dashboard/package-lock.json
generated
75
ui/litellm-dashboard/package-lock.json
generated
|
|
@ -14,7 +14,7 @@
|
|||
"@base-ui/react": "^1.6.0",
|
||||
"@headlessui/tailwindcss": "0.2.2",
|
||||
"@heroicons/react": "1.0.6",
|
||||
"@tanstack/react-pacer": "0.2.0",
|
||||
"@tanstack/react-pacer": "0.22.1",
|
||||
"@tanstack/react-query": "5.100.7",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@tremor/react": "3.18.7",
|
||||
|
|
@ -51,7 +51,6 @@
|
|||
"@testing-library/jest-dom": "6.9.1",
|
||||
"@testing-library/react": "16.3.2",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
"@types/lodash": "4.17.23",
|
||||
"@types/node": "20.19.37",
|
||||
"@types/react": "18.2.48",
|
||||
"@types/react-copy-to-clipboard": "5.0.7",
|
||||
|
|
@ -3619,11 +3618,31 @@
|
|||
"tailwindcss": "4.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/pacer": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/pacer/-/pacer-0.2.0.tgz",
|
||||
"integrity": "sha512-fUJs3NpSwtAL/tfq8kuYdgvm9HbbJvHsOG6aHY2dFDfff0NBFNwjvyGreWZZRPs2zgoIbr4nOk+rRV7aQgmf+A==",
|
||||
"node_modules/@tanstack/devtools-event-client": {
|
||||
"version": "0.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/devtools-event-client/-/devtools-event-client-0.4.4.tgz",
|
||||
"integrity": "sha512-6T5Yop/793YI+H+5J8Hsyj4kCih9sl4t3ElLgKioW5hk3ocn+ZdSJ94tT7vL7uabxSugWYBZlOTMPzEw2puvQw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"intent": "bin/intent.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/pacer": {
|
||||
"version": "0.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/pacer/-/pacer-0.21.1.tgz",
|
||||
"integrity": "sha512-hB01dd4rlsYcTCNP7wK186jgAe6K5qimgM1Y5Jtvz+9PUaILvpmeLLjmQNUNSO1l23lIt+CeQR6mO1mjlPvRtQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/devtools-event-client": "^0.4.3",
|
||||
"@tanstack/store": "^0.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
|
|
@ -3643,12 +3662,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-pacer": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-pacer/-/react-pacer-0.2.0.tgz",
|
||||
"integrity": "sha512-KU5GtjkKSeNdYCilen5Dc+Pu/6BPQbsQshKrUUjrg7URyJIiGBCz6ZZFre1QjDz/aeUeqUJWMWSm+2Dsh64v+w==",
|
||||
"version": "0.22.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-pacer/-/react-pacer-0.22.1.tgz",
|
||||
"integrity": "sha512-CenQqK0GluSPIrnsG1yuD7w5uMSQ/4lI9AcGEFxBrRd66r260boWcYRIsS5+eHtXb238FoZYhKmJPGlhRzmHRw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/pacer": "0.2.0"
|
||||
"@tanstack/pacer": "0.21.1",
|
||||
"@tanstack/react-store": "^0.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
|
@ -3678,6 +3698,24 @@
|
|||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-store": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.11.0.tgz",
|
||||
"integrity": "sha512-tX4YXh3PDkmpvGQWkWqKpzs/MSqbtuwY9dWdWhtV9Q50PmO+jOkUKIWIX4G85dwt7lxdHLXsiaEKPdKmC8F41w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/store": "0.11.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-table": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
|
||||
|
|
@ -3715,6 +3753,16 @@
|
|||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/store": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.11.0.tgz",
|
||||
"integrity": "sha512-WlzzCt3xi0G6pCAJu1U+2jiECwabETDpQDi3hfkFZvJii9AuZqEKbOiVarX1/bWhTNjU486yQtJCCasi/0q+Cw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/table-core": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
|
||||
|
|
@ -4060,13 +4108,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/mdast": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@
|
|||
"@base-ui/react": "^1.6.0",
|
||||
"@headlessui/tailwindcss": "0.2.2",
|
||||
"@heroicons/react": "1.0.6",
|
||||
"@tanstack/react-pacer": "0.2.0",
|
||||
"@tanstack/react-pacer": "0.22.1",
|
||||
"@tanstack/react-query": "5.100.7",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@tremor/react": "3.18.7",
|
||||
|
|
@ -67,7 +67,6 @@
|
|||
"@testing-library/jest-dom": "6.9.1",
|
||||
"@testing-library/react": "16.3.2",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
"@types/lodash": "4.17.23",
|
||||
"@types/node": "20.19.37",
|
||||
"@types/react": "18.2.48",
|
||||
"@types/react-copy-to-clipboard": "5.0.7",
|
||||
|
|
@ -95,7 +94,6 @@
|
|||
"js-yaml": "4.2.0",
|
||||
"glob": "13.0.0",
|
||||
"minimatch": "10.2.4",
|
||||
"lodash": "4.18.1",
|
||||
"ws": "8.21.0",
|
||||
"braces": "3.0.3",
|
||||
"axios": "1.13.6",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders } from "@/../tests/test-utils";
|
||||
import AgentCardDiscovery from "./agent_card_discovery";
|
||||
|
|
@ -243,6 +243,54 @@ describe("AgentCardDiscovery", () => {
|
|||
expect(selection.selected_card.capabilities.streaming).toBe(false);
|
||||
});
|
||||
|
||||
it("does not fire discovery before the debounce wait and fires once with the last URL", async () => {
|
||||
mockDiscover.mockResolvedValue({
|
||||
url: "https://last.example.com",
|
||||
agent_card: sampleCard,
|
||||
});
|
||||
renderWithProviders(<AgentCardDiscovery accessToken="tok" onApply={vi.fn()} />);
|
||||
const input = screen.getByPlaceholderText("https://upstream-agent.example.com");
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(input, { target: { value: "https://first.example.com" } });
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(399);
|
||||
});
|
||||
expect(mockDiscover).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(input, { target: { value: "https://last.example.com" } });
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(399);
|
||||
});
|
||||
expect(mockDiscover).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
});
|
||||
expect(mockDiscover).toHaveBeenCalledTimes(1);
|
||||
expect(mockDiscover).toHaveBeenCalledWith("tok", "https://last.example.com", undefined);
|
||||
});
|
||||
|
||||
it("fires no discovery when unmounted mid-wait", () => {
|
||||
const { unmount } = renderWithProviders(<AgentCardDiscovery accessToken="tok" onApply={vi.fn()} />);
|
||||
const input = screen.getByPlaceholderText("https://upstream-agent.example.com");
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(input, { target: { value: "https://first.example.com" } });
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
unmount();
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
expect(mockDiscover).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks discover when no access token is provided", async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
renderWithProviders(<AgentCardDiscovery accessToken={null} onApply={vi.fn()} />);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
SearchOutlined,
|
||||
} from "@ant-design/icons";
|
||||
|
||||
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
|
||||
import { DiscoveredAgentCard, discoverAgentCardCall } from "@/components/networking";
|
||||
import {
|
||||
ALLOWED_CAPABILITY_KEYS,
|
||||
|
|
@ -22,6 +23,8 @@ import {
|
|||
const { Text, Paragraph } = Typography;
|
||||
const { Panel } = Collapse;
|
||||
|
||||
const DISCOVERY_DEBOUNCE_WAIT_MS = 400;
|
||||
|
||||
export interface DiscoveredAgentCardSelection {
|
||||
/** Full upstream card the proxy fetched, unmodified. */
|
||||
raw_card: DiscoveredAgentCard;
|
||||
|
|
@ -171,6 +174,14 @@ const AgentCardDiscovery: React.FC<AgentCardDiscoveryProps> = ({
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [accessToken, effectiveUrl, isParentDriven, discoveryMode, discoveryParamsKey]);
|
||||
|
||||
const debouncedDiscover = useDebouncedCallback(
|
||||
() => {
|
||||
if (!accessToken || !effectiveUrl.trim()) return;
|
||||
void handleDiscover();
|
||||
},
|
||||
{ wait: DISCOVERY_DEBOUNCE_WAIT_MS },
|
||||
);
|
||||
|
||||
// Auto-discover when the URL (or parent plan) becomes available. Debounce
|
||||
// is applied uniformly so rapid changes from a watched parent form (e.g.
|
||||
// typing into a LangGraph api_base / assistant_id field) don't fire one
|
||||
|
|
@ -186,11 +197,8 @@ const AgentCardDiscovery: React.FC<AgentCardDiscoveryProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
void handleDiscover();
|
||||
}, 400);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [accessToken, effectiveUrl, handleDiscover]);
|
||||
debouncedDiscover();
|
||||
}, [accessToken, effectiveUrl, handleDiscover, debouncedDiscover]);
|
||||
|
||||
const toggleSkill = (id: string, checked: boolean) => {
|
||||
setSelectedSkillIds((prev) => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
import {
|
||||
SearchIcon,
|
||||
PlusIcon,
|
||||
|
|
@ -728,6 +730,7 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) {
|
|||
rejected: 0,
|
||||
});
|
||||
const [search, setSearch] = useState("");
|
||||
const [searchDebounced] = useDebouncedValue(search, { wait: DEBOUNCE_WAIT_MS });
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | GuardrailStatus>("all");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [expandedHeaders, setExpandedHeaders] = useState<Set<string>>(new Set());
|
||||
|
|
@ -737,16 +740,10 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) {
|
|||
} | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchDebounced, setSearchDebounced] = useState("");
|
||||
const [isSubmitModalOpen, setIsSubmitModalOpen] = useState(false);
|
||||
const [submitForm] = Form.useForm();
|
||||
const registerGuardrail = useRegisterGuardrail();
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setSearchDebounced(search), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [search]);
|
||||
|
||||
const fetchSubmissions = useCallback(async () => {
|
||||
if (!accessToken) {
|
||||
setIsLoading(false);
|
||||
|
|
|
|||
|
|
@ -84,6 +84,24 @@ export const teamListCall = async (
|
|||
}
|
||||
};
|
||||
|
||||
export const teamsTableKeys = createQueryKeys("teamsTable");
|
||||
|
||||
export const useTeamsTable = (
|
||||
page: number,
|
||||
pageSize: number,
|
||||
options: TeamListCallOptions = {},
|
||||
): UseQueryResult<TeamsResponse> => {
|
||||
const { accessToken } = useAuthorized();
|
||||
|
||||
return useQuery<TeamsResponse>({
|
||||
queryKey: teamsTableKeys.list({ page, limit: pageSize, ...options }),
|
||||
queryFn: async () => await teamListCall(accessToken!, page, pageSize, options),
|
||||
enabled: Boolean(accessToken),
|
||||
staleTime: 30000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
};
|
||||
|
||||
const teamKeys = createQueryKeys("teams");
|
||||
export const useTeams = (): UseQueryResult<Team[]> => {
|
||||
const { accessToken, userId, userRole } = useAuthorized();
|
||||
|
|
|
|||
|
|
@ -684,7 +684,6 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
|
|||
refetch();
|
||||
setByokModalServer(null);
|
||||
}}
|
||||
accessToken={accessToken || ""}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -296,7 +296,7 @@ const ModelsAndEndpointsView: React.FC<ModelDashboardProps> = ({ premiumUser, te
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="w-full mx-4 h-[75vh]">
|
||||
<div className="mx-4 h-[75vh]">
|
||||
<Grid numItems={1} className="gap-2 p-8 w-full mt-2">
|
||||
<Col numColSpan={1} className="flex flex-col gap-2">
|
||||
{/* Model Management Header */}
|
||||
|
|
|
|||
|
|
@ -14,11 +14,13 @@ import { useQueryClient } from "@tanstack/react-query";
|
|||
import { Grid, TabPanel } from "@tremor/react";
|
||||
import { Badge, Button, Select, Skeleton, Space, Typography } from "antd";
|
||||
import ModelSettingsModal from "@/components/model_dashboard/ModelSettingsModal/ModelSettingsModal";
|
||||
import debounce from "lodash/debounce";
|
||||
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useModelsInfo } from "../../hooks/models/useModels";
|
||||
import { transformModelData } from "../utils/modelDataTransformer";
|
||||
type ModelViewMode = "all" | "current_team";
|
||||
|
||||
const SEARCH_DEBOUNCE_WAIT_MS = 200;
|
||||
const { Text } = Typography;
|
||||
|
||||
interface AllModelsTabProps {
|
||||
|
|
@ -59,23 +61,17 @@ const AllModelsTab = ({
|
|||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [isModelSettingsModalVisible, setIsModelSettingsModalVisible] = useState(false);
|
||||
|
||||
// Debounce search input
|
||||
const debouncedUpdateSearch = useMemo(
|
||||
() =>
|
||||
debounce((value: string) => {
|
||||
setDebouncedSearch(value);
|
||||
// Reset to page 1 when search changes
|
||||
setCurrentPage(1);
|
||||
setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 }));
|
||||
}, 200),
|
||||
[],
|
||||
const debouncedUpdateSearch = useDebouncedCallback(
|
||||
(value: string) => {
|
||||
setDebouncedSearch(value);
|
||||
setCurrentPage(1);
|
||||
setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 }));
|
||||
},
|
||||
{ wait: SEARCH_DEBOUNCE_WAIT_MS },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
debouncedUpdateSearch(modelNameSearch);
|
||||
return () => {
|
||||
debouncedUpdateSearch.cancel();
|
||||
};
|
||||
}, [modelNameSearch, debouncedUpdateSearch]);
|
||||
|
||||
// Determine teamId to pass to the query - only pass if not "personal"
|
||||
|
|
|
|||
|
|
@ -191,7 +191,7 @@ const OrganizationsTable: React.FC<OrganizationsTableProps> = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="w-full mx-4 h-[75vh]">
|
||||
<div className="mx-4 h-[75vh]">
|
||||
<Grid numItems={1} className="gap-2 p-8 w-full mt-2">
|
||||
<Col numColSpan={1} className="flex flex-col gap-2">
|
||||
{(userRole === "Admin" || userRole === "Org Admin") && (
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ import { A2ATaskMetadata, MessageType } from "@/components/chat_ui/types";
|
|||
import { useCodeInterpreter } from "../../hooks/useCodeInterpreter";
|
||||
import { useChatHistory } from "../../hooks/useChatHistory";
|
||||
import { getSecureItem, setSecureItem } from "@/utils/secureStorage";
|
||||
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Dragger } = Upload;
|
||||
|
|
@ -99,6 +100,8 @@ interface ChatUIProps {
|
|||
|
||||
const MCP_SUPPORTED_ENDPOINTS = new Set<EndpointType>([EndpointType.CHAT, EndpointType.RESPONSES, EndpointType.MCP]);
|
||||
|
||||
const CUSTOM_MODEL_DEBOUNCE_WAIT_MS = 500;
|
||||
|
||||
const ChatUI: React.FC<ChatUIProps> = ({
|
||||
accessToken,
|
||||
token,
|
||||
|
|
@ -185,7 +188,9 @@ const ChatUI: React.FC<ChatUIProps> = ({
|
|||
const [modelInfo, setModelInfo] = useState<ModelGroup[]>([]);
|
||||
const [agentInfo, setAgentInfo] = useState<Agent[]>([]);
|
||||
const [selectedAgent, setSelectedAgent] = useState<string | undefined>(undefined);
|
||||
const customModelTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
const debouncedSetSelectedModel = useDebouncedCallback((value: string) => setSelectedModel(value), {
|
||||
wait: CUSTOM_MODEL_DEBOUNCE_WAIT_MS,
|
||||
});
|
||||
const [endpointType, setEndpointType] = useState<string>(
|
||||
() => sessionStorage.getItem("endpointType") || EndpointType.CHAT,
|
||||
);
|
||||
|
|
@ -1255,16 +1260,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
|
|||
<TextInput
|
||||
className="mt-2"
|
||||
placeholder="Enter custom model name"
|
||||
onValueChange={(value) => {
|
||||
// Using setTimeout to create a simple debounce effect
|
||||
if (customModelTimeout.current) {
|
||||
clearTimeout(customModelTimeout.current);
|
||||
}
|
||||
|
||||
customModelTimeout.current = setTimeout(() => {
|
||||
setSelectedModel(value);
|
||||
}, 500); // 500ms delay after typing stops
|
||||
}}
|
||||
onValueChange={debouncedSetSelectedModel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -2186,7 +2182,6 @@ const ChatUI: React.FC<ChatUIProps> = ({
|
|||
loadMCPServers();
|
||||
setByokModalServer(null);
|
||||
}}
|
||||
accessToken={accessToken || ""}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
import { ClearOutlined, DeleteOutlined, FilePdfOutlined, PlusOutlined } from "@ant-design/icons";
|
||||
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
|
||||
import { Button, Input, Select, Tooltip } from "antd";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
|
@ -105,14 +107,8 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }:
|
|||
disabledPersonalKeyCreation ? "custom" : "session",
|
||||
);
|
||||
const [customApiKey, setCustomApiKey] = useState("");
|
||||
const [debouncedCustomApiKey, setDebouncedCustomApiKey] = useState("");
|
||||
const [debouncedCustomApiKey] = useDebouncedValue(customApiKey, { wait: DEBOUNCE_WAIT_MS });
|
||||
const [customProxyBaseUrl] = useState<string>(() => sessionStorage.getItem("customProxyBaseUrl") || "");
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedCustomApiKey(customApiKey);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [customApiKey]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (uploadedFilePreviewUrl) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { renderHook, act } from "@testing-library/react";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useChatHistory } from "./useChatHistory";
|
||||
|
||||
describe("useChatHistory", () => {
|
||||
|
|
@ -499,6 +499,80 @@ describe("useChatHistory", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("debounced chatHistory persistence", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("should not write chatHistory to sessionStorage before the debounce wait elapses", () => {
|
||||
const setItemSpy = vi.spyOn(Storage.prototype, "setItem");
|
||||
const { result } = renderHook(() => useChatHistory({ simplified: false }));
|
||||
|
||||
act(() => {
|
||||
result.current.updateTextUI("user", "hello");
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(499);
|
||||
});
|
||||
|
||||
expect(setItemSpy.mock.calls.filter(([key]) => key === "chatHistory")).toHaveLength(0);
|
||||
|
||||
setItemSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should write chatHistory exactly once with the last value after the wait", () => {
|
||||
const setItemSpy = vi.spyOn(Storage.prototype, "setItem");
|
||||
const { result } = renderHook(() => useChatHistory({ simplified: false }));
|
||||
|
||||
act(() => {
|
||||
result.current.updateTextUI("user", "h");
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
act(() => {
|
||||
result.current.updateTextUI("user", "i");
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(499);
|
||||
});
|
||||
|
||||
expect(setItemSpy.mock.calls.filter(([key]) => key === "chatHistory")).toHaveLength(0);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1);
|
||||
});
|
||||
|
||||
const writes = setItemSpy.mock.calls.filter(([key]) => key === "chatHistory");
|
||||
expect(writes).toHaveLength(1);
|
||||
expect(JSON.parse(writes[0][1])).toEqual([{ role: "user", content: "hi" }]);
|
||||
|
||||
setItemSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should not write chatHistory when unmounted mid-wait", () => {
|
||||
const setItemSpy = vi.spyOn(Storage.prototype, "setItem");
|
||||
const { result, unmount } = renderHook(() => useChatHistory({ simplified: false }));
|
||||
|
||||
act(() => {
|
||||
result.current.updateTextUI("user", "hello");
|
||||
});
|
||||
unmount();
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
expect(setItemSpy.mock.calls.filter(([key]) => key === "chatHistory")).toHaveLength(0);
|
||||
|
||||
setItemSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("simplified mode session isolation", () => {
|
||||
it("should not hydrate messageTraceId from sessionStorage in simplified mode", () => {
|
||||
sessionStorage.setItem("messageTraceId", "trace-from-playground");
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { useDebouncer } from "@tanstack/react-pacer/debouncer";
|
||||
import { MessageType, A2ATaskMetadata } from "@/components/chat_ui/types";
|
||||
import { TokenUsage } from "@/components/chat_ui/ResponseMetrics";
|
||||
import { MCPEvent } from "@/components/mcp_tools/types";
|
||||
import { truncateString } from "@/utils/textUtils";
|
||||
|
||||
const CHAT_HISTORY_PERSIST_WAIT_MS = 500;
|
||||
|
||||
export interface UseChatHistoryReturn {
|
||||
// State
|
||||
chatHistory: MessageType[];
|
||||
|
|
@ -64,20 +67,20 @@ export function useChatHistory({ simplified }: { simplified: boolean }): UseChat
|
|||
return saved ? JSON.parse(saved) : true; // Default to API session management
|
||||
});
|
||||
|
||||
// Debounced chatHistory persistence
|
||||
useEffect(() => {
|
||||
if (simplified) return; // Do not persist chat history in simplified (embedded) mode
|
||||
// When chatHistory is empty (e.g. after clearChatHistory removed the key),
|
||||
// don't re-write an empty array back into sessionStorage.
|
||||
if (chatHistory.length === 0) return;
|
||||
const handler = setTimeout(() => {
|
||||
sessionStorage.setItem("chatHistory", JSON.stringify(chatHistory));
|
||||
}, 500); // Debounce by 500ms
|
||||
const persistDebouncer = useDebouncer(
|
||||
(history: MessageType[]) => {
|
||||
sessionStorage.setItem("chatHistory", JSON.stringify(history));
|
||||
},
|
||||
{ wait: CHAT_HISTORY_PERSIST_WAIT_MS },
|
||||
);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [chatHistory, simplified]);
|
||||
useEffect(() => {
|
||||
if (simplified || chatHistory.length === 0) {
|
||||
persistDebouncer.cancel();
|
||||
return;
|
||||
}
|
||||
persistDebouncer.maybeExecute(chatHistory);
|
||||
}, [chatHistory, simplified, persistDebouncer]);
|
||||
|
||||
// messageTraceId/responsesSessionId/useApiSessionManagement persistence
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ const TagManagement: React.FC<TagProps> = ({ accessToken, userID, userRole }) =>
|
|||
}, [accessToken]);
|
||||
|
||||
return (
|
||||
<div className="w-full mx-4 h-[75vh]">
|
||||
<div className="mx-4 h-[75vh]">
|
||||
{selectedTagId ? (
|
||||
<TagInfoView
|
||||
tagId={selectedTagId}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
import { DownOutlined, ExportOutlined, InfoCircleOutlined, LoadingOutlined, RightOutlined } from "@ant-design/icons";
|
||||
import { useDebouncedState } from "@tanstack/react-pacer/debouncer";
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
import {
|
||||
Card,
|
||||
Col,
|
||||
|
|
@ -94,7 +95,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
// Debounced search for user selector
|
||||
const [userSearchInput, setUserSearchInput] = useState("");
|
||||
const [debouncedUserSearch, setDebouncedUserSearch] = useDebouncedState("", {
|
||||
wait: 300,
|
||||
wait: DEBOUNCE_WAIT_MS,
|
||||
});
|
||||
|
||||
const {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
import OnboardingModal, { InvitationLink } from "@/components/onboarding_link";
|
||||
|
||||
import { updateExistingKeys } from "@/utils/dataUtils";
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
import { isAdminRole, isProxyAdminRole } from "@/utils/roles";
|
||||
import { useDebouncedState } from "@tanstack/react-pacer/debouncer";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
|
@ -86,7 +87,7 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
|||
const [userToDelete, setUserToDelete] = useState<UserInfo | null>(null);
|
||||
const [activeTab, setActiveTab] = useState("users");
|
||||
const [filters, setFilters] = useState<FilterState>(initialFilters);
|
||||
const [debouncedFilters, setDebouncedFilters, debouncer] = useDebouncedState(filters, { wait: 300 });
|
||||
const [debouncedFilters, setDebouncedFilters, debouncer] = useDebouncedState(filters, { wait: DEBOUNCE_WAIT_MS });
|
||||
const [isInvitationLinkModalVisible, setIsInvitationLinkModalVisible] = useState(false);
|
||||
const [invitationLinkData, setInvitationLinkData] = useState<InvitationLink | null>(null);
|
||||
const [baseUrl, setBaseUrl] = useState<string | null>(null);
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ const VectorStoreManagement: React.FC<VectorStoreProps> = ({ accessToken, userID
|
|||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full mx-4 h-[75vh]">
|
||||
<div className="mx-4 h-[75vh]">
|
||||
<div className="gap-2 p-8 h-[75vh] w-full mt-2">
|
||||
<div className="flex justify-between mt-2 w-full items-center mb-4">
|
||||
<h1>Vector Store Management</h1>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|||
|
||||
import WorkflowRuns from "./WorkflowRuns";
|
||||
|
||||
vi.mock("@/components/networking", () => ({ proxyBaseUrl: "" }));
|
||||
vi.mock("@/components/networking", () => ({
|
||||
proxyBaseUrl: "",
|
||||
getGlobalLitellmHeaderName: () => "x-litellm-api-key",
|
||||
}));
|
||||
|
||||
interface FakeRun {
|
||||
run_id: string;
|
||||
|
|
@ -78,4 +81,18 @@ describe("WorkflowRuns (migrated onto shared DataTable)", () => {
|
|||
|
||||
expect(await screen.findByText("No workflow runs yet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sends the configured litellm key header on every fetch instead of hardcoding Authorization", async () => {
|
||||
const user = userEvent.setup();
|
||||
const fetchSpy = mockFetch(RUNS);
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
render(<WorkflowRuns accessToken="tok" />);
|
||||
|
||||
await user.click(await screen.findByText("First run"));
|
||||
|
||||
await waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(3));
|
||||
for (const [url, init] of fetchSpy.mock.calls as [string, RequestInit][]) {
|
||||
expect(init.headers, url).toEqual({ "x-litellm-api-key": "Bearer tok" });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback, useMemo } from "react";
|
|||
import { Button, Collapse, Drawer, Empty, Spin, Tooltip, Typography } from "antd";
|
||||
import { ReloadOutlined } from "@ant-design/icons";
|
||||
import type { ColumnDef, ColumnFiltersState } from "@tanstack/react-table";
|
||||
import { proxyBaseUrl } from "@/components/networking";
|
||||
import { getGlobalLitellmHeaderName, proxyBaseUrl } from "@/components/networking";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFilterDrawer,
|
||||
|
|
@ -507,7 +507,7 @@ const WorkflowRuns: React.FC<WorkflowRunsProps> = ({ accessToken }) => {
|
|||
setLoadingRuns(true);
|
||||
try {
|
||||
const res = await fetch(`${proxyBaseUrl ?? ""}/v1/workflows/runs?limit=100`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
headers: { [getGlobalLitellmHeaderName()]: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
|
|
@ -531,10 +531,10 @@ const WorkflowRuns: React.FC<WorkflowRunsProps> = ({ accessToken }) => {
|
|||
const base = proxyBaseUrl ?? "";
|
||||
const [evRes, msgRes] = await Promise.all([
|
||||
fetch(`${base}/v1/workflows/runs/${run.run_id}/events`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
headers: { [getGlobalLitellmHeaderName()]: `Bearer ${accessToken}` },
|
||||
}),
|
||||
fetch(`${base}/v1/workflows/runs/${run.run_id}/messages`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
headers: { [getGlobalLitellmHeaderName()]: `Bearer ${accessToken}` },
|
||||
}),
|
||||
]);
|
||||
const evData = evRes.ok ? await evRes.json() : { events: [] };
|
||||
|
|
|
|||
|
|
@ -11,6 +11,25 @@
|
|||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* shadcn Base UI primitives reference these variants; upstream omits them (shadcn-ui/ui#9196) */
|
||||
@custom-variant data-open (&:where([data-state="open"], [data-open]:not([data-open="false"])));
|
||||
@custom-variant data-closed (&:where([data-state="closed"], [data-closed]:not([data-closed="false"])));
|
||||
@custom-variant data-checked (&:where([data-state="checked"], [data-checked]:not([data-checked="false"])));
|
||||
@custom-variant data-unchecked (&:where([data-state="unchecked"], [data-unchecked]:not([data-unchecked="false"])));
|
||||
@custom-variant data-selected (&:where([data-selected="true"]));
|
||||
@custom-variant data-disabled (&:where([data-disabled="true"], [data-disabled]:not([data-disabled="false"])));
|
||||
@custom-variant data-active (&:where([data-state="active"], [data-active]:not([data-active="false"])));
|
||||
@custom-variant data-horizontal (&:where([data-orientation="horizontal"]));
|
||||
@custom-variant data-vertical (&:where([data-orientation="vertical"]));
|
||||
|
||||
@utility no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.5rem;
|
||||
--background: oklch(1 0 0);
|
||||
|
|
|
|||
|
|
@ -382,7 +382,7 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="w-full mx-4 h-[75vh]">
|
||||
<div className="mx-4 h-[75vh]">
|
||||
{publicPage == false ? (
|
||||
<div className="w-full m-2 mt-2 p-8">
|
||||
{/* Header with Title, Description and URL */}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases";
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
import { LoadingOutlined } from "@ant-design/icons";
|
||||
import { useDebouncedState } from "@tanstack/react-pacer/debouncer";
|
||||
import { Select } from "antd";
|
||||
|
|
@ -16,7 +17,6 @@ export interface PaginatedKeyAliasSelectProps {
|
|||
}
|
||||
|
||||
const SCROLL_THRESHOLD = 0.8;
|
||||
const DEBOUNCE_MS = 300;
|
||||
|
||||
export const PaginatedKeyAliasSelect = ({
|
||||
value,
|
||||
|
|
@ -30,7 +30,7 @@ export const PaginatedKeyAliasSelect = ({
|
|||
}: PaginatedKeyAliasSelectProps) => {
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useDebouncedState("", {
|
||||
wait: DEBOUNCE_MS,
|
||||
wait: DEBOUNCE_WAIT_MS,
|
||||
});
|
||||
|
||||
const teamId = allFilters?.["Team ID"] || undefined;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useInfiniteModelInfo } from "@/app/(dashboard)/hooks/models/useModels";
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
import { LoadingOutlined } from "@ant-design/icons";
|
||||
import { useDebouncedState } from "@tanstack/react-pacer/debouncer";
|
||||
import { Select, Space, Typography } from "antd";
|
||||
|
|
@ -17,7 +18,6 @@ export interface PaginatedModelSelectProps {
|
|||
}
|
||||
|
||||
const SCROLL_THRESHOLD = 0.8;
|
||||
const DEBOUNCE_MS = 300;
|
||||
|
||||
export const PaginatedModelSelect = ({
|
||||
value,
|
||||
|
|
@ -30,7 +30,7 @@ export const PaginatedModelSelect = ({
|
|||
}: PaginatedModelSelectProps) => {
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useDebouncedState("", {
|
||||
wait: DEBOUNCE_MS,
|
||||
wait: DEBOUNCE_WAIT_MS,
|
||||
});
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteModelInfo(
|
||||
|
|
|
|||
|
|
@ -5,11 +5,20 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||
import { fetchAvailableModelsForTeamOrKey } from "./key_team_helpers/fetch_available_models_team_key";
|
||||
import { fetchMCPAccessGroups, getGuardrailsList, teamCreateCall } from "./networking";
|
||||
import Teams from "./Teams";
|
||||
import { teamListCall } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
|
||||
const mockTeamInfoView = vi.fn();
|
||||
const mockUseOrganizations = vi.fn();
|
||||
|
||||
// The teams grid is unit-tested in TeamsPage/TeamsTable.test.tsx. Here we stub it and drive its callbacks
|
||||
// directly so we can test the Teams shell wiring (delete modal, detail view) without the real DataTable.
|
||||
let mockTeamsTableProps: any = null;
|
||||
vi.mock("./TeamsPage/TeamsTable", () => ({
|
||||
TeamsTable: (props: any) => {
|
||||
mockTeamsTableProps = props;
|
||||
return <div data-testid="teams-table-stub" />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./networking", () => ({
|
||||
teamCreateCall: vi.fn(),
|
||||
teamDeleteCall: vi.fn(),
|
||||
|
|
@ -19,8 +28,9 @@ vi.mock("./networking", () => ({
|
|||
getPoliciesList: vi.fn().mockResolvedValue({ policies: [] }),
|
||||
}));
|
||||
|
||||
// Teams invalidates teamsTableKeys on mutations; the selected team is passed up from the table.
|
||||
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
|
||||
teamListCall: vi.fn().mockResolvedValue({ teams: [], total: 0, page: 1, page_size: 100, total_pages: 0 }),
|
||||
teamsTableKeys: { all: ["teamsTable"] },
|
||||
}));
|
||||
|
||||
vi.mock("./molecules/notifications_manager", () => ({
|
||||
|
|
@ -116,6 +126,21 @@ vi.mock("./common_components/AccessGroupSelector", () => ({
|
|||
),
|
||||
}));
|
||||
|
||||
const baseTableTeam = {
|
||||
team_id: "1",
|
||||
team_alias: "Test Team",
|
||||
organization_id: "org-123",
|
||||
models: ["gpt-4"],
|
||||
max_budget: 100,
|
||||
budget_duration: "1d",
|
||||
tpm_limit: 1000,
|
||||
rpm_limit: 1000,
|
||||
created_at: new Date().toISOString(),
|
||||
keys: [],
|
||||
members_with_roles: [],
|
||||
spend: 0,
|
||||
};
|
||||
|
||||
const createQueryClient = () => {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
|
|
@ -131,10 +156,16 @@ const renderWithQueryClient = (component: React.ReactElement) => {
|
|||
return render(<QueryClientProvider client={queryClient}>{component}</QueryClientProvider>);
|
||||
};
|
||||
|
||||
// Re-establish safe defaults before every test (clearAllMocks keeps return values, so restore them here).
|
||||
beforeEach(() => {
|
||||
mockTeamsTableProps = null;
|
||||
});
|
||||
|
||||
describe("Teams - handleCreate organization handling", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockTeamInfoView.mockClear();
|
||||
mockTeamsTableProps = null;
|
||||
vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue([]);
|
||||
vi.mocked(fetchMCPAccessGroups).mockResolvedValue([]);
|
||||
vi.mocked(getGuardrailsList).mockResolvedValue({ guardrails: [] });
|
||||
|
|
@ -142,7 +173,6 @@ describe("Teams - handleCreate organization handling", () => {
|
|||
});
|
||||
|
||||
it("should not include organization_id when it's an empty string", async () => {
|
||||
const mockAccessToken = "test-token";
|
||||
const formValues: Record<string, any> = {
|
||||
team_alias: "Test Team",
|
||||
organization_id: "", // Empty string
|
||||
|
|
@ -168,7 +198,6 @@ describe("Teams - handleCreate organization handling", () => {
|
|||
models: [],
|
||||
};
|
||||
|
||||
// Simulate the handleCreate logic
|
||||
let organizationId = formValues?.organization_id || null;
|
||||
if (organizationId === "" || typeof organizationId !== "string") {
|
||||
formValues.organization_id = null;
|
||||
|
|
@ -182,11 +211,10 @@ describe("Teams - handleCreate organization handling", () => {
|
|||
it("should trim and keep valid organization_id string", async () => {
|
||||
const formValues: Record<string, any> = {
|
||||
team_alias: "Test Team",
|
||||
organization_id: " org-123 ", // String with whitespace
|
||||
organization_id: " org-123 ",
|
||||
models: [],
|
||||
};
|
||||
|
||||
// Simulate the handleCreate logic
|
||||
let organizationId = formValues?.organization_id || null;
|
||||
if (organizationId === "" || typeof organizationId !== "string") {
|
||||
formValues.organization_id = null;
|
||||
|
|
@ -204,7 +232,6 @@ describe("Teams - handleCreate organization handling", () => {
|
|||
models: [],
|
||||
};
|
||||
|
||||
// Simulate the handleCreate logic
|
||||
let organizationId = formValues?.organization_id || null;
|
||||
if (organizationId === "" || typeof organizationId !== "string") {
|
||||
formValues.organization_id = null;
|
||||
|
|
@ -223,7 +250,6 @@ describe("Teams - handleCreate organization handling", () => {
|
|||
max_budget: 100,
|
||||
};
|
||||
|
||||
// Simulate the handleCreate logic
|
||||
let organizationId = formValues?.organization_id || null;
|
||||
if (organizationId === "" || typeof organizationId !== "string") {
|
||||
formValues.organization_id = null;
|
||||
|
|
@ -231,18 +257,13 @@ describe("Teams - handleCreate organization handling", () => {
|
|||
formValues.organization_id = organizationId.trim();
|
||||
}
|
||||
|
||||
// Verify the structure
|
||||
expect(formValues).toEqual({
|
||||
team_alias: "Test Team",
|
||||
organization_id: null,
|
||||
models: ["gpt-4"],
|
||||
max_budget: 100,
|
||||
});
|
||||
|
||||
// Verify we're not sending an empty string
|
||||
expect(formValues.organization_id).not.toBe("");
|
||||
|
||||
// Verify it's explicitly null, not undefined
|
||||
expect(formValues.organization_id).toBeNull();
|
||||
});
|
||||
|
||||
|
|
@ -259,7 +280,6 @@ describe("Teams - handleCreate organization handling", () => {
|
|||
models: [],
|
||||
};
|
||||
|
||||
// Simulate the handleCreate logic with currentOrg fallback
|
||||
let organizationId = formValues?.organization_id || currentOrg?.organization_id;
|
||||
if (organizationId === "" || typeof organizationId !== "string") {
|
||||
formValues.organization_id = null;
|
||||
|
|
@ -270,204 +290,27 @@ describe("Teams - handleCreate organization handling", () => {
|
|||
expect(formValues.organization_id).toBe("fallback-org-id");
|
||||
});
|
||||
|
||||
it("should not include organizations as an empty array in the request payload", async () => {
|
||||
const mockTeamCreateCall = vi.mocked(teamCreateCall);
|
||||
const mockAccessToken = "test-token";
|
||||
|
||||
const formValues = {
|
||||
team_alias: "Test Team",
|
||||
organization_id: "org-123",
|
||||
models: ["gpt-4"],
|
||||
organizations: [], // This should never be sent
|
||||
};
|
||||
|
||||
// Remove organizations key if it's empty
|
||||
if (Array.isArray(formValues.organizations) && formValues.organizations.length === 0) {
|
||||
delete (formValues as any).organizations;
|
||||
}
|
||||
|
||||
// Verify organizations key is removed
|
||||
expect(formValues).not.toHaveProperty("organizations");
|
||||
expect(formValues).toEqual({
|
||||
team_alias: "Test Team",
|
||||
organization_id: "org-123",
|
||||
models: ["gpt-4"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle organization_id validation for org admins", () => {
|
||||
// This test simulates the validation that should happen for org admins
|
||||
const isOrgAdmin = true;
|
||||
const formValues: Record<string, any> = {
|
||||
team_alias: "Test Team",
|
||||
// organization_id is missing/undefined
|
||||
};
|
||||
|
||||
// For org admins, organization_id should be required
|
||||
const hasOrganization =
|
||||
formValues.organization_id !== undefined &&
|
||||
formValues.organization_id !== null &&
|
||||
formValues.organization_id !== "";
|
||||
|
||||
if (isOrgAdmin && !hasOrganization) {
|
||||
// This should trigger validation error
|
||||
expect(hasOrganization).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("should allow null organization_id for global admins", () => {
|
||||
const isAdmin = true;
|
||||
const formValues: Record<string, any> = {
|
||||
team_alias: "Test Team",
|
||||
organization_id: null,
|
||||
models: [],
|
||||
};
|
||||
|
||||
// Global admins can create teams without an organization
|
||||
if (isAdmin) {
|
||||
expect(formValues.organization_id).toBeNull();
|
||||
// This is valid for admins
|
||||
}
|
||||
});
|
||||
|
||||
it("should ensure organization_id is never an empty list", () => {
|
||||
const invalidFormValues: Record<string, any> = {
|
||||
team_alias: "Test Team",
|
||||
organization_id: [], // Wrong type - should be string or null
|
||||
};
|
||||
|
||||
// Type check: organization_id should never be an array
|
||||
expect(Array.isArray(invalidFormValues.organization_id)).toBe(true);
|
||||
|
||||
// Correct it to null
|
||||
if (Array.isArray(invalidFormValues.organization_id)) {
|
||||
invalidFormValues.organization_id = null;
|
||||
}
|
||||
|
||||
expect(invalidFormValues.organization_id).toBeNull();
|
||||
expect(Array.isArray(invalidFormValues.organization_id)).toBe(false);
|
||||
});
|
||||
|
||||
it("should clear the delete modal when the cancel button is clicked", async () => {
|
||||
it("opens the delete modal when the table's delete action fires", async () => {
|
||||
mockUseOrganizations.mockReturnValue({ data: [] });
|
||||
vi.mocked(teamListCall).mockResolvedValue({
|
||||
teams: [
|
||||
{
|
||||
team_id: "1",
|
||||
team_alias: "Test Team",
|
||||
organization_id: "org-123",
|
||||
models: ["gpt-4"],
|
||||
max_budget: 100,
|
||||
budget_duration: "1d",
|
||||
tpm_limit: 1000,
|
||||
rpm_limit: 1000,
|
||||
created_at: new Date().toISOString(),
|
||||
keys: [],
|
||||
members_with_roles: [],
|
||||
spend: 0,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
total_pages: 1,
|
||||
});
|
||||
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("delete-team-button")).toBeInTheDocument();
|
||||
});
|
||||
const deleteTeamButton = screen.getByTestId("delete-team-button");
|
||||
act(() => {
|
||||
fireEvent.click(deleteTeamButton);
|
||||
|
||||
await waitFor(() => expect(mockTeamsTableProps).not.toBeNull());
|
||||
await act(async () => {
|
||||
mockTeamsTableProps.onDeleteTeam(baseTableTeam);
|
||||
});
|
||||
|
||||
expect(screen.getByText("Delete Team?")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Teams - empty state", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseOrganizations.mockReturnValue({ data: [] });
|
||||
});
|
||||
|
||||
it("should display empty state message when teams array is empty", async () => {
|
||||
vi.mocked(teamListCall).mockResolvedValue({ teams: [], total: 0, page: 1, page_size: 100, total_pages: 1 });
|
||||
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No teams yet")).toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.getByText("Create your first team to organize members and manage access to models."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display empty state message when teams is null", async () => {
|
||||
vi.mocked(teamListCall).mockResolvedValue({ teams: [], total: 0, page: 1, page_size: 100, total_pages: 1 });
|
||||
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No teams yet")).toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.getByText("Create your first team to organize members and manage access to models."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not display empty state when teams array has items", async () => {
|
||||
vi.mocked(teamListCall).mockResolvedValue({
|
||||
teams: [
|
||||
{
|
||||
team_id: "1",
|
||||
team_alias: "Test Team",
|
||||
organization_id: "org-123",
|
||||
models: ["gpt-4"],
|
||||
max_budget: 100,
|
||||
budget_duration: "1d",
|
||||
tpm_limit: 1000,
|
||||
rpm_limit: 1000,
|
||||
created_at: new Date().toISOString(),
|
||||
keys: [],
|
||||
members_with_roles: [],
|
||||
spend: 0,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
total_pages: 1,
|
||||
});
|
||||
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Team")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText("No teams yet")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Create your first team to organize members and manage access to models."),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Teams - helper functions", () => {
|
||||
describe("getAdminOrganizations", () => {
|
||||
it("should return all organizations for Admin role", () => {
|
||||
const organizations = [
|
||||
{
|
||||
organization_id: "org-1",
|
||||
organization_alias: "Org 1",
|
||||
models: [],
|
||||
members: [],
|
||||
},
|
||||
{
|
||||
organization_id: "org-2",
|
||||
organization_alias: "Org 2",
|
||||
models: [],
|
||||
members: [],
|
||||
},
|
||||
{ organization_id: "org-1", organization_alias: "Org 1", models: [], members: [] },
|
||||
{ organization_id: "org-2", organization_alias: "Org 2", models: [], members: [] },
|
||||
];
|
||||
|
||||
// Simulate getAdminOrganizations logic for Admin
|
||||
const userRole = "Admin";
|
||||
const result = userRole === "Admin" ? organizations : [];
|
||||
|
||||
|
|
@ -477,7 +320,6 @@ describe("Teams - helper functions", () => {
|
|||
|
||||
it("should return only org_admin organizations for Org Admin role", () => {
|
||||
const userID = "user-123";
|
||||
const userRole = "Org Admin";
|
||||
const organizations = [
|
||||
{
|
||||
organization_id: "org-1",
|
||||
|
|
@ -499,7 +341,6 @@ describe("Teams - helper functions", () => {
|
|||
},
|
||||
];
|
||||
|
||||
// Simulate getAdminOrganizations logic
|
||||
const result = organizations.filter((org) =>
|
||||
org.members?.some((member) => member.user_id === userID && member.user_role === "org_admin"),
|
||||
);
|
||||
|
|
@ -519,7 +360,6 @@ describe("Teams - helper functions", () => {
|
|||
},
|
||||
];
|
||||
|
||||
// Simulate getAdminOrganizations logic
|
||||
const result = organizations.filter((org) =>
|
||||
org.members?.some((member) => member.user_id === userID && member.user_role === "org_admin"),
|
||||
);
|
||||
|
|
@ -531,8 +371,7 @@ describe("Teams - helper functions", () => {
|
|||
describe("canCreateOrManageTeams", () => {
|
||||
it("should return true for Admin role", () => {
|
||||
const userRole = "Admin";
|
||||
const result = userRole === "Admin";
|
||||
expect(result).toBe(true);
|
||||
expect(userRole === "Admin").toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for org_admin in any organization", () => {
|
||||
|
|
@ -577,6 +416,7 @@ describe("Teams - helper functions", () => {
|
|||
|
||||
describe("Teams - premium props", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockTeamInfoView.mockClear();
|
||||
vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue([]);
|
||||
vi.mocked(fetchMCPAccessGroups).mockResolvedValue([]);
|
||||
|
|
@ -584,38 +424,14 @@ describe("Teams - premium props", () => {
|
|||
mockUseOrganizations.mockReturnValue({ data: [] });
|
||||
});
|
||||
|
||||
it("passes premiumUser flag to TeamInfoView", async () => {
|
||||
vi.mocked(teamListCall).mockResolvedValue({
|
||||
teams: [
|
||||
{
|
||||
team_id: "team-123456789",
|
||||
team_alias: "Premium Team",
|
||||
organization_id: "org-123",
|
||||
models: ["gpt-4"],
|
||||
max_budget: 100,
|
||||
budget_duration: "1d",
|
||||
tpm_limit: 1000,
|
||||
rpm_limit: 1000,
|
||||
created_at: new Date().toISOString(),
|
||||
keys: [],
|
||||
members_with_roles: [],
|
||||
spend: 0,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
total_pages: 1,
|
||||
});
|
||||
it("passes premiumUser flag to TeamInfoView when a team is opened", async () => {
|
||||
const premiumTeam = { ...baseTableTeam, team_id: "team-123456789", team_alias: "Premium Team" };
|
||||
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" premiumUser={true} />);
|
||||
|
||||
const teamIdElement = await screen.findByText("team-123456789");
|
||||
act(() => {
|
||||
fireEvent.click(teamIdElement);
|
||||
});
|
||||
await waitFor(() => expect(mockTeamsTableProps).not.toBeNull());
|
||||
act(() => mockTeamsTableProps.onSelectTeam(premiumTeam));
|
||||
|
||||
await waitFor(() => expect(mockTeamInfoView).toHaveBeenCalled());
|
||||
|
||||
expect(mockTeamInfoView).toHaveBeenLastCalledWith(expect.objectContaining({ premiumUser: true }));
|
||||
});
|
||||
});
|
||||
|
|
@ -627,114 +443,22 @@ describe("Teams - Default Team Settings tab visibility", () => {
|
|||
});
|
||||
|
||||
it("should show Default Team Settings tab for Admin role", () => {
|
||||
vi.mocked(teamListCall).mockResolvedValue({
|
||||
teams: [
|
||||
{
|
||||
team_id: "1",
|
||||
team_alias: "Test Team",
|
||||
organization_id: "org-123",
|
||||
models: ["gpt-4"],
|
||||
max_budget: 100,
|
||||
budget_duration: "1d",
|
||||
tpm_limit: 1000,
|
||||
rpm_limit: 1000,
|
||||
created_at: new Date().toISOString(),
|
||||
keys: [],
|
||||
members_with_roles: [],
|
||||
spend: 0,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
total_pages: 1,
|
||||
});
|
||||
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
|
||||
|
||||
expect(screen.getByRole("tab", { name: "Default Team Settings" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show Default Team Settings tab for proxy_admin role", () => {
|
||||
vi.mocked(teamListCall).mockResolvedValue({
|
||||
teams: [
|
||||
{
|
||||
team_id: "1",
|
||||
team_alias: "Test Team",
|
||||
organization_id: "org-123",
|
||||
models: ["gpt-4"],
|
||||
max_budget: 100,
|
||||
budget_duration: "1d",
|
||||
tpm_limit: 1000,
|
||||
rpm_limit: 1000,
|
||||
created_at: new Date().toISOString(),
|
||||
keys: [],
|
||||
members_with_roles: [],
|
||||
spend: 0,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
total_pages: 1,
|
||||
});
|
||||
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="proxy_admin" />);
|
||||
|
||||
expect(screen.getByRole("tab", { name: "Default Team Settings" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show Default Team Settings tab for proxy_admin_viewer role", () => {
|
||||
vi.mocked(teamListCall).mockResolvedValue({
|
||||
teams: [
|
||||
{
|
||||
team_id: "1",
|
||||
team_alias: "Test Team",
|
||||
organization_id: "org-123",
|
||||
models: ["gpt-4"],
|
||||
max_budget: 100,
|
||||
budget_duration: "1d",
|
||||
tpm_limit: 1000,
|
||||
rpm_limit: 1000,
|
||||
created_at: new Date().toISOString(),
|
||||
keys: [],
|
||||
members_with_roles: [],
|
||||
spend: 0,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
total_pages: 1,
|
||||
});
|
||||
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="proxy_admin_viewer" />);
|
||||
|
||||
expect(screen.queryByRole("tab", { name: "Default Team Settings" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show Default Team Settings tab for Admin Viewer role", () => {
|
||||
vi.mocked(teamListCall).mockResolvedValue({
|
||||
teams: [
|
||||
{
|
||||
team_id: "1",
|
||||
team_alias: "Test Team",
|
||||
organization_id: "org-123",
|
||||
models: ["gpt-4"],
|
||||
max_budget: 100,
|
||||
budget_duration: "1d",
|
||||
tpm_limit: 1000,
|
||||
rpm_limit: 1000,
|
||||
created_at: new Date().toISOString(),
|
||||
keys: [],
|
||||
members_with_roles: [],
|
||||
spend: 0,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
total_pages: 1,
|
||||
});
|
||||
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin Viewer" />);
|
||||
|
||||
expect(screen.queryByRole("tab", { name: "Default Team Settings" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -761,7 +485,6 @@ describe("Teams - access_group_ids in team create", () => {
|
|||
});
|
||||
|
||||
it("should pass access_group_ids to teamCreateCall when creating team", async () => {
|
||||
vi.mocked(teamListCall).mockResolvedValue({ teams: [], total: 0, page: 1, page_size: 100, total_pages: 1 });
|
||||
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
|
||||
|
||||
const createButton = screen.getAllByRole("button", { name: /create team/i })[0];
|
||||
|
|
@ -773,25 +496,19 @@ describe("Teams - access_group_ids in team create", () => {
|
|||
expect(screen.getByLabelText(/team name/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const teamNameInput = screen.getByLabelText(/team name/i);
|
||||
fireEvent.change(teamNameInput, { target: { value: "Test Team" } });
|
||||
fireEvent.change(screen.getByLabelText(/team name/i), { target: { value: "Test Team" } });
|
||||
fireEvent.change(screen.getByTestId("create-team-models-select"), { target: { value: "gpt-4" } });
|
||||
|
||||
const modelsInput = screen.getByTestId("create-team-models-select");
|
||||
fireEvent.change(modelsInput, { target: { value: "gpt-4" } });
|
||||
|
||||
const additionalSettingsAccordion = screen.getByText("Additional Settings");
|
||||
fireEvent.click(additionalSettingsAccordion);
|
||||
fireEvent.click(screen.getByText("Additional Settings"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("access-group-selector")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const accessGroupInput = screen.getByTestId("access-group-selector");
|
||||
fireEvent.change(accessGroupInput, { target: { value: "ag-1,ag-2" } });
|
||||
fireEvent.change(screen.getByTestId("access-group-selector"), { target: { value: "ag-1,ag-2" } });
|
||||
|
||||
const createTeamSubmitButtons = screen.getAllByRole("button", { name: /create team/i });
|
||||
const createTeamSubmitButton = createTeamSubmitButtons[createTeamSubmitButtons.length - 1];
|
||||
fireEvent.click(createTeamSubmitButton);
|
||||
fireEvent.click(createTeamSubmitButtons[createTeamSubmitButtons.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(teamCreateCall).toHaveBeenCalledWith(
|
||||
|
|
@ -814,9 +531,6 @@ describe("Teams - models dropdown options", () => {
|
|||
});
|
||||
|
||||
it("should not render all-proxy-models option in models select", async () => {
|
||||
vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue(["gpt-4", "gpt-3.5-turbo"]);
|
||||
|
||||
vi.mocked(teamListCall).mockResolvedValue({ teams: [], total: 0, page: 1, page_size: 100, total_pages: 1 });
|
||||
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -831,207 +545,7 @@ describe("Teams - models dropdown options", () => {
|
|||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/models/i)).toBeInTheDocument();
|
||||
});
|
||||
const allProxyModelsOption = screen.queryByText("All Proxy Models");
|
||||
expect(allProxyModelsOption).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Teams - organization alias display", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseOrganizations.mockReturnValue({ data: [] });
|
||||
});
|
||||
|
||||
it("should display organization alias instead of organization id", async () => {
|
||||
const mockOrganizations = [
|
||||
{
|
||||
organization_id: "org-123",
|
||||
organization_alias: "Test Organization",
|
||||
budget_id: "budget-1",
|
||||
metadata: {},
|
||||
models: [],
|
||||
spend: 0,
|
||||
model_spend: {},
|
||||
created_at: new Date().toISOString(),
|
||||
created_by: "user-1",
|
||||
updated_at: new Date().toISOString(),
|
||||
updated_by: "user-1",
|
||||
litellm_budget_table: null,
|
||||
teams: null,
|
||||
users: null,
|
||||
members: null,
|
||||
},
|
||||
];
|
||||
|
||||
mockUseOrganizations.mockReturnValue({ data: mockOrganizations });
|
||||
|
||||
vi.mocked(teamListCall).mockResolvedValue({
|
||||
teams: [
|
||||
{
|
||||
team_id: "1",
|
||||
team_alias: "Test Team",
|
||||
organization_id: "org-123",
|
||||
models: ["gpt-4"],
|
||||
max_budget: 100,
|
||||
budget_duration: "1d",
|
||||
tpm_limit: 1000,
|
||||
rpm_limit: 1000,
|
||||
created_at: new Date().toISOString(),
|
||||
keys: [],
|
||||
members_with_roles: [],
|
||||
spend: 0,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
total_pages: 1,
|
||||
});
|
||||
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Organization")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText("org-123")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display organization id when alias is not found", async () => {
|
||||
mockUseOrganizations.mockReturnValue({ data: [] });
|
||||
|
||||
vi.mocked(teamListCall).mockResolvedValue({
|
||||
teams: [
|
||||
{
|
||||
team_id: "1",
|
||||
team_alias: "Test Team",
|
||||
organization_id: "org-unknown",
|
||||
models: ["gpt-4"],
|
||||
max_budget: 100,
|
||||
budget_duration: "1d",
|
||||
tpm_limit: 1000,
|
||||
rpm_limit: 1000,
|
||||
created_at: new Date().toISOString(),
|
||||
keys: [],
|
||||
members_with_roles: [],
|
||||
spend: 0,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
total_pages: 1,
|
||||
});
|
||||
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("org-unknown")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display N/A when organization_id is null", async () => {
|
||||
mockUseOrganizations.mockReturnValue({ data: [] });
|
||||
|
||||
vi.mocked(teamListCall).mockResolvedValue({
|
||||
teams: [
|
||||
{
|
||||
team_id: "1",
|
||||
team_alias: "Test Team",
|
||||
organization_id: null,
|
||||
models: ["gpt-4"],
|
||||
max_budget: 100,
|
||||
budget_duration: "1d",
|
||||
tpm_limit: 1000,
|
||||
rpm_limit: 1000,
|
||||
created_at: new Date().toISOString(),
|
||||
keys: [],
|
||||
members_with_roles: [],
|
||||
spend: 0,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
total_pages: 1,
|
||||
});
|
||||
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
|
||||
|
||||
await waitFor(() => {
|
||||
// When organization_id is null, the table shows "—" in the Organization column
|
||||
expect(screen.getAllByText("—").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Teams - Resources column keys badge", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseOrganizations.mockReturnValue({ data: [] });
|
||||
});
|
||||
|
||||
it("renders keys_count from the v2 payload in the Resources badge", async () => {
|
||||
vi.mocked(teamListCall).mockResolvedValue({
|
||||
teams: [
|
||||
{
|
||||
team_id: "1",
|
||||
team_alias: "Team With Keys",
|
||||
organization_id: "org-123",
|
||||
models: ["gpt-4"],
|
||||
max_budget: 100,
|
||||
budget_duration: "1d",
|
||||
tpm_limit: 1000,
|
||||
rpm_limit: 1000,
|
||||
created_at: new Date().toISOString(),
|
||||
keys: [],
|
||||
keys_count: 3,
|
||||
members_with_roles: [],
|
||||
spend: 0,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
total_pages: 1,
|
||||
});
|
||||
const { container } = renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Team With Keys")).toBeInTheDocument();
|
||||
});
|
||||
const cyanTag = container.querySelector(".ant-tag-cyan");
|
||||
expect(cyanTag).not.toBeNull();
|
||||
expect(cyanTag?.textContent).toContain("3");
|
||||
});
|
||||
|
||||
it("falls back to keys.length when keys_count is absent", async () => {
|
||||
vi.mocked(teamListCall).mockResolvedValue({
|
||||
teams: [
|
||||
{
|
||||
team_id: "2",
|
||||
team_alias: "Legacy Team",
|
||||
organization_id: "org-123",
|
||||
models: ["gpt-4"],
|
||||
max_budget: 100,
|
||||
budget_duration: "1d",
|
||||
tpm_limit: 1000,
|
||||
rpm_limit: 1000,
|
||||
created_at: new Date().toISOString(),
|
||||
keys: [{ token: "t1" }, { token: "t2" }],
|
||||
members_with_roles: [],
|
||||
spend: 0,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
total_pages: 1,
|
||||
});
|
||||
const { container } = renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Legacy Team")).toBeInTheDocument();
|
||||
});
|
||||
const cyanTag = container.querySelector(".ant-tag-cyan");
|
||||
expect(cyanTag).not.toBeNull();
|
||||
expect(cyanTag?.textContent).toContain("2");
|
||||
expect(screen.queryByText("All Proxy Models")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1042,39 +556,16 @@ describe("Teams - delete team warning copy", () => {
|
|||
});
|
||||
|
||||
const openDeleteModal = async (team: any) => {
|
||||
vi.mocked(teamListCall).mockResolvedValue({
|
||||
teams: [team],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
total_pages: 1,
|
||||
});
|
||||
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("delete-team-button")).toBeInTheDocument();
|
||||
});
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByTestId("delete-team-button"));
|
||||
await waitFor(() => expect(mockTeamsTableProps).not.toBeNull());
|
||||
await act(async () => {
|
||||
mockTeamsTableProps.onDeleteTeam(team);
|
||||
});
|
||||
expect(screen.getByText("Delete Team?")).toBeInTheDocument();
|
||||
};
|
||||
|
||||
const baseTeam = {
|
||||
team_id: "1",
|
||||
team_alias: "Test Team",
|
||||
organization_id: "org-123",
|
||||
models: ["gpt-4"],
|
||||
max_budget: 100,
|
||||
budget_duration: "1d",
|
||||
tpm_limit: 1000,
|
||||
rpm_limit: 1000,
|
||||
created_at: new Date().toISOString(),
|
||||
members_with_roles: [],
|
||||
spend: 0,
|
||||
};
|
||||
|
||||
it("warns that the team's models are deleted when the team has keys", async () => {
|
||||
await openDeleteModal({ ...baseTeam, keys: [], keys_count: 5 });
|
||||
await openDeleteModal({ ...baseTableTeam, keys: [], keys_count: 5 });
|
||||
|
||||
expect(screen.getByText(/Warning: This team has 5 keys associated with it/i)).toHaveTextContent(
|
||||
/along with any models created for this team/i,
|
||||
|
|
@ -1085,7 +576,7 @@ describe("Teams - delete team warning copy", () => {
|
|||
});
|
||||
|
||||
it("still warns about model deletion in the confirmation message when the team has no keys", async () => {
|
||||
await openDeleteModal({ ...baseTeam, keys: [], keys_count: 0 });
|
||||
await openDeleteModal({ ...baseTableTeam, keys: [], keys_count: 0 });
|
||||
|
||||
expect(screen.queryByText(/Warning: This team has/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/Are you sure you want to delete this team/i)).toHaveTextContent(
|
||||
|
|
@ -1101,7 +592,6 @@ describe("Teams - LIT-2530 organization stays optional for proxy admin with a si
|
|||
vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue(["gpt-4"]);
|
||||
vi.mocked(fetchMCPAccessGroups).mockResolvedValue([]);
|
||||
vi.mocked(getGuardrailsList).mockResolvedValue({ guardrails: [] });
|
||||
vi.mocked(teamListCall).mockResolvedValue({ teams: [], total: 0, page: 1, page_size: 100, total_pages: 1 });
|
||||
vi.mocked(teamCreateCall).mockResolvedValue({
|
||||
team_id: "new-team-1",
|
||||
team_alias: "No Org Team",
|
||||
|
|
|
|||
|
|
@ -3,38 +3,16 @@ import AvailableTeamsPanel from "@/components/team/available_teams";
|
|||
import TeamInfoView from "@/components/team/TeamInfo";
|
||||
import TeamSSOSettings from "@/components/TeamSSOSettings";
|
||||
import { isProxyAdminRole } from "@/utils/roles";
|
||||
import { InfoCircleOutlined, PlusOutlined, TeamOutlined, ReloadOutlined } from "@ant-design/icons";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { Accordion, AccordionBody, AccordionHeader, TextInput } from "@tremor/react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Flex,
|
||||
Form,
|
||||
Input,
|
||||
Layout,
|
||||
Modal,
|
||||
Pagination,
|
||||
Progress,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
theme,
|
||||
Tooltip,
|
||||
Typography,
|
||||
message,
|
||||
} from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import type { SorterResult } from "antd/es/table/interface";
|
||||
import { KeyIcon, LayersIcon, SearchIcon, UsersIcon } from "lucide-react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AntDLoadingSpinner } from "@/components/ui/AntDLoadingSpinner";
|
||||
import { DateCell, IdCell } from "@/components/shared/table_cells";
|
||||
import OrganizationDropdown from "./common_components/OrganizationDropdown";
|
||||
import TableIconActionButton from "./common_components/IconActionButton/TableIconActionButtons/TableIconActionButton";
|
||||
import { teamListCall as v2TeamListCall, type TeamsResponse } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import { Button, Form, Input, Layout, Modal, Select, Switch, Tabs, theme, Tooltip, Typography } from "antd";
|
||||
import { Plus, Users } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { PageHeader } from "@/components/shared/PageHeader";
|
||||
import { Button as UIButton } from "@/components/ui/button";
|
||||
import { teamsTableKeys } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import { TeamsTable } from "./TeamsPage/TeamsTable";
|
||||
import AccessGroupSelector from "./common_components/AccessGroupSelector";
|
||||
import PassThroughRoutesSelector from "./common_components/PassThroughRoutesSelector";
|
||||
import AgentSelector from "./agent_management/AgentSelector";
|
||||
|
|
@ -45,7 +23,7 @@ import {
|
|||
fetchAvailableModelsForTeamOrKey,
|
||||
unfurlWildcardModelsInList,
|
||||
} from "./key_team_helpers/fetch_available_models_team_key";
|
||||
import type { KeyResponse, Team } from "./key_team_helpers/key_list";
|
||||
import type { Team } from "./key_team_helpers/key_list";
|
||||
import MCPServerSelector from "./mcp_server_management/MCPServerSelector";
|
||||
import MCPToolPermissions from "./mcp_server_management/MCPToolPermissions";
|
||||
import NotificationsManager from "./molecules/notifications_manager";
|
||||
|
|
@ -61,13 +39,6 @@ interface TeamProps {
|
|||
premiumUser?: boolean;
|
||||
}
|
||||
|
||||
interface FilterState {
|
||||
search: string;
|
||||
organization_id: string;
|
||||
sort_by: string;
|
||||
sort_order: "asc" | "desc";
|
||||
}
|
||||
|
||||
interface EditTeamModalProps {
|
||||
visible: boolean;
|
||||
onCancel: () => void;
|
||||
|
|
@ -75,21 +46,10 @@ interface EditTeamModalProps {
|
|||
onSubmit: (data: FormData) => void; // Assuming FormData is the type of data to be submitted
|
||||
}
|
||||
|
||||
import { updateExistingKeys } from "@/utils/dataUtils";
|
||||
import DeleteResourceModal from "./common_components/DeleteResourceModal";
|
||||
import { Member, teamCreateCall } from "./networking";
|
||||
import { teamCreateCall } from "./networking";
|
||||
import { ModelSelect } from "./ModelSelect/ModelSelect";
|
||||
|
||||
interface TeamInfo {
|
||||
members_with_roles: Member[];
|
||||
}
|
||||
|
||||
interface PerTeamInfo {
|
||||
keys: KeyResponse[];
|
||||
keys_count: number;
|
||||
team_info: TeamInfo;
|
||||
}
|
||||
|
||||
const getOrganizationModels = (organization: Organization | null, userModels: string[]) => {
|
||||
let tempModelsToPick = [];
|
||||
|
||||
|
|
@ -164,70 +124,17 @@ const getOrganizationAlias = (
|
|||
const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser = false }) => {
|
||||
const { data: organizationsData } = useOrganizations();
|
||||
const organizations = organizationsData ?? null;
|
||||
const [teams, setTeams] = useState<Team[] | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [totalTeams, setTotalTeams] = useState(0);
|
||||
const [currentOrg, setCurrentOrg] = useState<Organization | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const refreshTeams = () => queryClient.invalidateQueries({ queryKey: teamsTableKeys.all });
|
||||
const [currentOrg] = useState<Organization | null>(null);
|
||||
const [currentOrgForCreateTeam, setCurrentOrgForCreateTeam] = useState<Organization | null>(null);
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
search: "",
|
||||
organization_id: "",
|
||||
sort_by: "created_at",
|
||||
sort_order: "desc",
|
||||
});
|
||||
const searchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
const fetchTeamsV2 = async (
|
||||
opts: {
|
||||
page?: number;
|
||||
size?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
organizationID?: string;
|
||||
search?: string;
|
||||
} = {},
|
||||
) => {
|
||||
if (!accessToken) return;
|
||||
const page = opts.page ?? currentPage;
|
||||
const size = opts.size ?? pageSize;
|
||||
const sortBy = opts.sortBy ?? filters.sort_by;
|
||||
const sortOrder = opts.sortOrder ?? filters.sort_order;
|
||||
const organizationID = opts.organizationID ?? filters.organization_id;
|
||||
const search = opts.search ?? filters.search;
|
||||
|
||||
setIsLoading(true);
|
||||
setFetchError(null);
|
||||
try {
|
||||
const response: TeamsResponse = await v2TeamListCall(accessToken, page, size, {
|
||||
organizationID: organizationID || null,
|
||||
search: search || null,
|
||||
userID: userRole !== "Admin" && userRole !== "Admin Viewer" ? userID : null,
|
||||
sortBy: sortBy || null,
|
||||
sortOrder: sortOrder || null,
|
||||
});
|
||||
setTeams(response.teams ?? []);
|
||||
setTotalTeams(response.total ?? 0);
|
||||
} catch (err: any) {
|
||||
setFetchError(err?.message || "Failed to fetch teams");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTeamsV2();
|
||||
}, [accessToken]);
|
||||
|
||||
const [form] = Form.useForm();
|
||||
const [memberForm] = Form.useForm();
|
||||
const [value, setValue] = useState("");
|
||||
const [editModalVisible, setEditModalVisible] = useState(false);
|
||||
|
||||
const [selectedTeam, setSelectedTeam] = useState<null | any>(null);
|
||||
const [selectedTeam, setSelectedTeam] = useState<Team | null>(null);
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
|
||||
const [editTeam, setEditTeam] = useState<boolean>(false);
|
||||
|
||||
|
|
@ -238,7 +145,6 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [teamToDelete, setTeamToDelete] = useState<Team | null>(null);
|
||||
const [modelsToPick, setModelsToPick] = useState<string[]>([]);
|
||||
const [perTeamInfo, setPerTeamInfo] = useState<Record<string, PerTeamInfo>>({});
|
||||
const [isTeamDeleting, setIsTeamDeleting] = useState(false);
|
||||
// Add this state near the other useState declarations
|
||||
const [guardrailsList, setGuardrailsList] = useState<string[]>([]);
|
||||
|
|
@ -325,30 +231,6 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
fetchMcpAccessGroups();
|
||||
}, [accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTeamInfo = () => {
|
||||
if (!teams) return;
|
||||
|
||||
const newPerTeamInfo = teams.reduce(
|
||||
(acc, team) => {
|
||||
acc[team.team_id] = {
|
||||
keys: team.keys || [],
|
||||
keys_count: team.keys_count ?? team.keys?.length ?? 0,
|
||||
team_info: {
|
||||
members_with_roles: team.members_with_roles || [],
|
||||
},
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, PerTeamInfo>,
|
||||
);
|
||||
|
||||
setPerTeamInfo(newPerTeamInfo);
|
||||
};
|
||||
|
||||
fetchTeamInfo();
|
||||
}, [teams]);
|
||||
|
||||
const handleOk = () => {
|
||||
setIsTeamModalVisible(false);
|
||||
form.resetFields();
|
||||
|
|
@ -386,14 +268,14 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (teamToDelete == null || teams == null || accessToken == null) {
|
||||
if (teamToDelete == null || accessToken == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsTeamDeleting(true);
|
||||
await teamDeleteCall(accessToken, teamToDelete.team_id);
|
||||
await fetchTeamsV2();
|
||||
await refreshTeams();
|
||||
NotificationsManager.success("Team deleted successfully");
|
||||
} catch (error) {
|
||||
NotificationsManager.fromBackend("Error deleting the team: " + error);
|
||||
|
|
@ -425,13 +307,11 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
};
|
||||
|
||||
fetchUserModels();
|
||||
}, [accessToken, userID, userRole, teams]);
|
||||
}, [accessToken, userID, userRole]);
|
||||
|
||||
const handleCreate = async (formValues: Record<string, any>) => {
|
||||
try {
|
||||
if (accessToken != null) {
|
||||
const newTeamAlias = formValues?.team_alias;
|
||||
const existingTeamAliases = teams?.map((t) => t.team_alias) ?? [];
|
||||
let organizationId = formValues?.organization_id || currentOrg?.organization_id;
|
||||
if (organizationId === "" || typeof organizationId !== "string") {
|
||||
formValues.organization_id = null;
|
||||
|
|
@ -439,11 +319,6 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
formValues.organization_id = organizationId.trim();
|
||||
}
|
||||
|
||||
// Remove guardrails from top level since it's now in metadata
|
||||
if (existingTeamAliases.includes(newTeamAlias)) {
|
||||
throw new Error(`Team alias ${newTeamAlias} already exists, please pick another alias`);
|
||||
}
|
||||
|
||||
NotificationsManager.info("Creating Team");
|
||||
|
||||
// Handle logging settings in metadata
|
||||
|
|
@ -565,10 +440,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
|
||||
await teamCreateCall(accessToken, formValues);
|
||||
NotificationsManager.success("Team created");
|
||||
await fetchTeamsV2({
|
||||
page: currentPage,
|
||||
size: pageSize,
|
||||
});
|
||||
await refreshTeams();
|
||||
form.resetFields();
|
||||
setLoggingSettings([]);
|
||||
setModelAliases({});
|
||||
|
|
@ -595,352 +467,31 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
return false;
|
||||
};
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current);
|
||||
setIsSearching(true);
|
||||
searchDebounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
setFilters((prev) => ({ ...prev, search: value }));
|
||||
setCurrentPage(1);
|
||||
await fetchTeamsV2({ page: 1, search: value });
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const handleFilterChange = async (key: keyof FilterState, value: string) => {
|
||||
const newFilters = { ...filters, [key]: value };
|
||||
setFilters(newFilters);
|
||||
setCurrentPage(1);
|
||||
if (!accessToken) return;
|
||||
try {
|
||||
const response: TeamsResponse = await v2TeamListCall(accessToken, 1, pageSize, {
|
||||
organizationID: newFilters.organization_id || null,
|
||||
search: newFilters.search || null,
|
||||
userID: userRole !== "Admin" && userRole !== "Admin Viewer" ? userID : null,
|
||||
sortBy: newFilters.sort_by || null,
|
||||
sortOrder: newFilters.sort_order || null,
|
||||
});
|
||||
setTeams(response.teams ?? []);
|
||||
setTotalTeams(response.total ?? 0);
|
||||
} catch (error) {
|
||||
console.error("Error fetching teams:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilterReset = () => {
|
||||
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current);
|
||||
setIsSearching(false);
|
||||
const resetFilters: FilterState = {
|
||||
search: "",
|
||||
organization_id: "",
|
||||
sort_by: "created_at",
|
||||
sort_order: "desc",
|
||||
};
|
||||
setFilters(resetFilters);
|
||||
setCurrentPage(1);
|
||||
fetchTeamsV2({ page: 1, organizationID: "", search: "", sortBy: "created_at", sortOrder: "desc" });
|
||||
};
|
||||
|
||||
const { token } = theme.useToken();
|
||||
const { Title, Text } = Typography;
|
||||
const { Text } = Typography;
|
||||
const { Content } = Layout;
|
||||
|
||||
const handleRetry = () => {
|
||||
fetchTeamsV2();
|
||||
};
|
||||
|
||||
const handleTableSort = (
|
||||
_pagination: unknown,
|
||||
_filters: unknown,
|
||||
sorter: SorterResult<Team> | SorterResult<Team>[],
|
||||
) => {
|
||||
const s = Array.isArray(sorter) ? sorter[0] : sorter;
|
||||
const sortBy = s.order ? (s.columnKey as string) : "created_at";
|
||||
const sortOrder = s.order === "ascend" ? "asc" : s.order === "descend" ? "desc" : "desc";
|
||||
setFilters((prev) => ({ ...prev, sort_by: sortBy, sort_order: sortOrder }));
|
||||
fetchTeamsV2({ sortBy, sortOrder });
|
||||
};
|
||||
|
||||
const teamColumns: ColumnsType<Team> = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: "Team ID",
|
||||
dataIndex: "team_id",
|
||||
key: "team_id",
|
||||
width: 170,
|
||||
ellipsis: true,
|
||||
render: (id: string) => (
|
||||
<IdCell value={id} onClick={(teamId) => setSelectedTeamId(teamId)} dataTestId="team-id-cell" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Team Alias",
|
||||
dataIndex: "team_alias",
|
||||
key: "team_alias",
|
||||
ellipsis: true,
|
||||
sorter: true,
|
||||
render: (alias: string | undefined) => (
|
||||
<Text style={{ fontSize: 14 }}>
|
||||
{alias || (
|
||||
<Text type="secondary" italic>
|
||||
—
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Organization",
|
||||
key: "organization",
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
render: (_: unknown, record: Team) => {
|
||||
const orgAlias = getOrganizationAlias(record.organization_id, organizations);
|
||||
return record.organization_id ? (
|
||||
<Text ellipsis style={{ fontSize: 14 }}>
|
||||
{orgAlias}
|
||||
</Text>
|
||||
) : (
|
||||
<Text type="secondary">—</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Resources",
|
||||
key: "resources",
|
||||
width: 240,
|
||||
render: (_: unknown, record: Team) => {
|
||||
const memberCount = perTeamInfo?.[record.team_id]?.team_info?.members_with_roles?.length ?? 0;
|
||||
const modelCount = record.models?.length ?? 0;
|
||||
const keyCount = perTeamInfo?.[record.team_id]?.keys_count ?? 0;
|
||||
return (
|
||||
<Flex gap={12} align="center">
|
||||
<Tooltip title={`${memberCount} Members`}>
|
||||
<Tag color="purple" style={{ fontSize: 14, padding: "2px 8px", margin: 0 }}>
|
||||
<Flex align="center" gap={6}>
|
||||
<UsersIcon size={14} />
|
||||
{memberCount}
|
||||
</Flex>
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
<Tooltip title={`${modelCount} Models`}>
|
||||
<Tag color="blue" style={{ fontSize: 14, padding: "2px 8px", margin: 0 }}>
|
||||
<Flex align="center" gap={6}>
|
||||
<LayersIcon size={14} />
|
||||
{modelCount}
|
||||
</Flex>
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
<Tooltip title={`${keyCount} Keys`}>
|
||||
<Tag color="cyan" style={{ fontSize: 14, padding: "2px 8px", margin: 0 }}>
|
||||
<Flex align="center" gap={6}>
|
||||
<KeyIcon size={14} />
|
||||
{keyCount}
|
||||
</Flex>
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Spend / Budget",
|
||||
key: "spend",
|
||||
width: 200,
|
||||
sorter: true,
|
||||
render: (_: unknown, record: Team) => {
|
||||
const spendVal = record.spend ?? 0;
|
||||
const budgetVal = record.max_budget;
|
||||
const spendStr = `$${spendVal.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
const budgetStr =
|
||||
budgetVal != null
|
||||
? `$${budgetVal.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
: "Unlimited";
|
||||
const percent = budgetVal != null && budgetVal > 0 ? Math.min((spendVal / budgetVal) * 100, 100) : null;
|
||||
return (
|
||||
<Flex vertical gap={2}>
|
||||
<Text style={{ fontSize: 13 }}>
|
||||
{spendStr}
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{" / "}
|
||||
{budgetStr}
|
||||
</Text>
|
||||
</Text>
|
||||
{percent != null && (
|
||||
<Progress
|
||||
percent={percent}
|
||||
size="small"
|
||||
showInfo={false}
|
||||
strokeColor={percent >= 90 ? "#ff4d4f" : percent >= 70 ? "#faad14" : "#1677ff"}
|
||||
style={{ marginBottom: 0 }}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Created",
|
||||
dataIndex: "created_at",
|
||||
key: "created_at",
|
||||
width: 130,
|
||||
ellipsis: true,
|
||||
sorter: true,
|
||||
render: (date: string | undefined) => <DateCell value={date} precision="date" />,
|
||||
},
|
||||
{
|
||||
title: "Actions",
|
||||
key: "actions",
|
||||
width: 120,
|
||||
align: "right" as const,
|
||||
render: (_: unknown, record: Team) => (
|
||||
<Space size={4}>
|
||||
<TableIconActionButton
|
||||
variant="Copy"
|
||||
tooltipText="Copy Team ID"
|
||||
onClick={() => {
|
||||
navigator.clipboard
|
||||
.writeText(record.team_id)
|
||||
.then(() => message.success("Team ID copied"))
|
||||
.catch(() => message.error("Failed to copy"));
|
||||
}}
|
||||
/>
|
||||
{userRole === "Admin" && (
|
||||
<>
|
||||
<TableIconActionButton
|
||||
variant="Edit"
|
||||
tooltipText="Edit team"
|
||||
dataTestId="edit-team-button"
|
||||
onClick={() => {
|
||||
setSelectedTeamId(record.team_id);
|
||||
setEditTeam(true);
|
||||
}}
|
||||
/>
|
||||
<TableIconActionButton
|
||||
variant="Delete"
|
||||
tooltipText="Delete team"
|
||||
dataTestId="delete-team-button"
|
||||
onClick={() => handleDelete(record)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
],
|
||||
[userRole, perTeamInfo, organizations],
|
||||
);
|
||||
|
||||
const displayTeams = useMemo(() => teams ?? [], [teams]);
|
||||
|
||||
const renderTeamsContent = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Flex justify="center" align="center" style={{ padding: "80px 0" }}>
|
||||
<AntDLoadingSpinner fontSize={48} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
if (fetchError) {
|
||||
return (
|
||||
<Flex vertical align="center" gap={16} style={{ padding: "64px 0" }}>
|
||||
<Text type="danger" style={{ fontSize: 15 }}>
|
||||
Failed to load teams
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
{fetchError}
|
||||
</Text>
|
||||
<Button icon={<ReloadOutlined />} onClick={handleRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table<Team>
|
||||
columns={teamColumns}
|
||||
dataSource={displayTeams}
|
||||
rowKey="team_id"
|
||||
pagination={false}
|
||||
onChange={handleTableSort}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<div style={{ padding: "64px 0", textAlign: "center" }}>
|
||||
<TeamOutlined style={{ fontSize: 40, color: "#d9d9d9", marginBottom: 12 }} />
|
||||
<div>
|
||||
<Text style={{ fontSize: 15, color: "#595959" }}>No teams yet</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
Create your first team to organize members and manage access to models.
|
||||
</Text>
|
||||
</div>
|
||||
{canCreateOrManageTeams(userRole, userID, organizations) && (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setIsTeamModalVisible(true)}
|
||||
style={{ marginTop: 16 }}
|
||||
data-testid="create-team-button"
|
||||
>
|
||||
Create Team
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
scroll={{ x: 1000 }}
|
||||
size="middle"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: "your-teams",
|
||||
label: "Your Teams",
|
||||
children: (
|
||||
<>
|
||||
<Card styles={{ body: { padding: 0 } }}>
|
||||
<Flex justify="space-between" align="center" style={{ padding: "12px 16px" }}>
|
||||
<Flex gap={12} align="center">
|
||||
<Input
|
||||
prefix={<SearchIcon size={16} />}
|
||||
suffix={isSearching ? <AntDLoadingSpinner size="small" /> : null}
|
||||
placeholder="Search teams by name or ID..."
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
allowClear
|
||||
style={{ maxWidth: 400 }}
|
||||
/>
|
||||
<OrganizationDropdown
|
||||
organizations={organizations}
|
||||
value={filters.organization_id || undefined}
|
||||
onChange={(value: string) => handleFilterChange("organization_id", value || "")}
|
||||
loading={isLoading}
|
||||
/>
|
||||
</Flex>
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
total={totalTeams}
|
||||
pageSize={pageSize}
|
||||
onChange={(page, size) => {
|
||||
setCurrentPage(page);
|
||||
setPageSize(size);
|
||||
fetchTeamsV2({ page, size });
|
||||
}}
|
||||
size="small"
|
||||
showTotal={(total) => `${total} teams`}
|
||||
showSizeChanger
|
||||
pageSizeOptions={["10", "20", "50"]}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
{renderTeamsContent()}
|
||||
</Card>
|
||||
<TeamsTable
|
||||
userRole={userRole}
|
||||
userID={userID}
|
||||
onSelectTeam={(team) => {
|
||||
setSelectedTeam(team);
|
||||
setSelectedTeamId(team.team_id);
|
||||
setEditTeam(false);
|
||||
}}
|
||||
onEditTeam={(team) => {
|
||||
setSelectedTeam(team);
|
||||
setSelectedTeamId(team.team_id);
|
||||
setEditTeam(true);
|
||||
}}
|
||||
onDeleteTeam={handleDelete}
|
||||
/>
|
||||
|
||||
<DeleteResourceModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
|
|
@ -991,26 +542,16 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
{selectedTeamId ? (
|
||||
<TeamInfoView
|
||||
teamId={selectedTeamId}
|
||||
onUpdate={(data) => {
|
||||
setTeams((teams) => {
|
||||
if (teams == null) {
|
||||
return teams;
|
||||
}
|
||||
return teams.map((team) => {
|
||||
if (data.team_id === team.team_id) {
|
||||
return updateExistingKeys(team, data);
|
||||
}
|
||||
return team;
|
||||
});
|
||||
});
|
||||
fetchTeamsV2();
|
||||
onUpdate={() => {
|
||||
refreshTeams();
|
||||
}}
|
||||
onClose={() => {
|
||||
setSelectedTeam(null);
|
||||
setSelectedTeamId(null);
|
||||
setEditTeam(false);
|
||||
}}
|
||||
accessToken={accessToken}
|
||||
is_team_admin={is_team_admin(teams?.find((team) => team.team_id === selectedTeamId))}
|
||||
is_team_admin={is_team_admin(selectedTeam)}
|
||||
is_proxy_admin={userRole == "Admin"}
|
||||
userModels={userModels}
|
||||
editTeam={editTeam}
|
||||
|
|
@ -1018,25 +559,21 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
/>
|
||||
) : (
|
||||
<>
|
||||
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
|
||||
<Space direction="vertical" size={0}>
|
||||
<Title level={2} style={{ margin: 0 }}>
|
||||
<TeamOutlined style={{ marginRight: 8 }} />
|
||||
Teams
|
||||
</Title>
|
||||
<Text type="secondary">Manage teams, members, and their access to models and budgets</Text>
|
||||
</Space>
|
||||
{canCreateOrManageTeams(userRole, userID, organizations) && (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setIsTeamModalVisible(true)}
|
||||
data-testid="create-team-button"
|
||||
>
|
||||
Create Team
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
<div className="mb-4">
|
||||
<PageHeader
|
||||
icon={<Users className="size-5" />}
|
||||
title="Teams"
|
||||
subtitle="Manage teams, members, and their access to models and budgets"
|
||||
actions={
|
||||
canCreateOrManageTeams(userRole, userID, organizations) ? (
|
||||
<UIButton onClick={() => setIsTeamModalVisible(true)} data-testid="create-team-button">
|
||||
<Plus className="size-4" />
|
||||
Create Team
|
||||
</UIButton>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs items={tabItems} />
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,330 @@
|
|||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, MockedFunction, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "../../../tests/test-utils";
|
||||
import { Team } from "../key_team_helpers/key_list";
|
||||
import { TeamsResponse, useTeamsTable } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import { TeamsTable } from "./TeamsTable";
|
||||
|
||||
// Resolve debounced values synchronously so an applied filter lands in the useTeamsTable query within the test tick.
|
||||
vi.mock("@tanstack/react-pacer/debouncer", async () => {
|
||||
const React = await vi.importActual<typeof import("react")>("react");
|
||||
return {
|
||||
useDebouncedValue: (value: unknown) => [value, { cancel: vi.fn(), flush: vi.fn() }],
|
||||
useDebouncedState: (initial: unknown) => {
|
||||
const [value, setValue] = React.useState(initial);
|
||||
return [value, setValue, { cancel: vi.fn(), flush: vi.fn() }];
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||
default: vi.fn(() => ({
|
||||
accessToken: "test-token",
|
||||
userId: "test-user",
|
||||
userRole: "Admin",
|
||||
premiumUser: true,
|
||||
token: "test-token",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
|
||||
useTeamsTable: vi.fn(),
|
||||
teamsTableKeys: { all: ["teamsTable"] },
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({
|
||||
useOrganizations: vi.fn().mockReturnValue({
|
||||
data: [{ organization_id: "org-1", organization_alias: "Test Organization" }],
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockTeam: Team = {
|
||||
team_id: "team-1",
|
||||
team_alias: "Acme Team",
|
||||
models: ["gpt-4", "gpt-3.5-turbo", "claude-3", "claude-3-5-sonnet"],
|
||||
max_budget: 100,
|
||||
budget_duration: "1mo",
|
||||
tpm_limit: 5000,
|
||||
rpm_limit: 500,
|
||||
organization_id: "org-1",
|
||||
created_at: "2024-10-01T10:00:00Z",
|
||||
updated_at: "2024-11-01T10:00:00Z",
|
||||
keys: [],
|
||||
keys_count: 3,
|
||||
members_with_roles: [
|
||||
{ user_id: "u1", user_email: "a@x.com", role: "admin" },
|
||||
{ user_id: "u2", user_email: "b@x.com", role: "user" },
|
||||
] as unknown as Team["members_with_roles"],
|
||||
spend: 42.5,
|
||||
};
|
||||
|
||||
const mockUseTeamsTable = useTeamsTable as MockedFunction<typeof useTeamsTable>;
|
||||
|
||||
const teamsResult = (teams: Team[], data: Partial<TeamsResponse> = {}, extra: Record<string, unknown> = {}) =>
|
||||
({
|
||||
data: {
|
||||
teams,
|
||||
total: teams.length,
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
total_pages: 1,
|
||||
...data,
|
||||
} as TeamsResponse,
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
...extra,
|
||||
}) as any;
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const renderTable = (props: Partial<React.ComponentProps<typeof TeamsTable>> = {}) =>
|
||||
renderWithProviders(
|
||||
<TeamsTable
|
||||
userRole="Admin"
|
||||
userID="admin-1"
|
||||
onSelectTeam={noop}
|
||||
onEditTeam={noop}
|
||||
onDeleteTeam={noop}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
|
||||
const openFilters = () => fireEvent.click(screen.getByRole("button", { name: "Filters" }));
|
||||
const lastOptions = () => mockUseTeamsTable.mock.calls[mockUseTeamsTable.mock.calls.length - 1][2] ?? {};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseTeamsTable.mockReturnValue(teamsResult([mockTeam]));
|
||||
});
|
||||
|
||||
it("renders a team row with alias, organization, and spend/budget", async () => {
|
||||
renderTable();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Acme Team")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Organization")).toBeInTheDocument();
|
||||
expect(screen.getByText("$42.5000")).toBeInTheDocument();
|
||||
expect(screen.getByText("of $100")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the Resources cell with member, model, and key counts", () => {
|
||||
renderTable();
|
||||
|
||||
expect(screen.getByTitle("2 members")).toBeInTheDocument();
|
||||
expect(screen.getByTitle("4 models")).toBeInTheDocument();
|
||||
expect(screen.getByTitle("3 keys")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'No teams found' when the list is empty", () => {
|
||||
mockUseTeamsTable.mockReturnValue(teamsResult([]));
|
||||
renderTable();
|
||||
expect(screen.getByText("No teams found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a loading state on initial load and hides the data", () => {
|
||||
mockUseTeamsTable.mockReturnValue(teamsResult([], {}, { data: null, isPending: true, isFetching: true }));
|
||||
renderTable();
|
||||
|
||||
expect(screen.getByText("Loading teams...")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Acme Team")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("sort contract – only backend-sortable columns are sortable", () => {
|
||||
it("requests the default created_at descending sort on first render", () => {
|
||||
renderTable();
|
||||
expect(lastOptions()).toMatchObject({ sortBy: "created_at", sortOrder: "desc" });
|
||||
});
|
||||
|
||||
it("sorts by the backend team_alias field (not the label) when the Team header is clicked", async () => {
|
||||
renderTable();
|
||||
fireEvent.click(screen.getByText("Team").closest("button") as HTMLElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUseTeamsTable).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ sortBy: "team_alias" }));
|
||||
});
|
||||
});
|
||||
|
||||
it("does not make Spend / Budget sortable (the backend rejects sort_by=spend)", () => {
|
||||
renderTable();
|
||||
expect(screen.getByText("Spend / Budget").closest("button")).toBeNull();
|
||||
// Team and Created are the only sortable headers.
|
||||
expect(screen.getByText("Team").closest("button")).not.toBeNull();
|
||||
expect(screen.getByText("Created").closest("button")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("server-side filtering maps controls to the right query params", () => {
|
||||
it("sends no filter params when nothing is applied", () => {
|
||||
renderTable();
|
||||
expect(lastOptions()).toMatchObject({ organizationID: undefined, team_alias: undefined, teamID: undefined });
|
||||
});
|
||||
|
||||
it("threads an applied Team alias filter into the query", async () => {
|
||||
renderTable();
|
||||
openFilters();
|
||||
|
||||
fireEvent.change(await screen.findByPlaceholderText(/Enter team alias/), { target: { value: "acme" } });
|
||||
fireEvent.click(screen.getByTestId("filter-drawer-apply"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUseTeamsTable).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ team_alias: "acme" }));
|
||||
});
|
||||
});
|
||||
|
||||
it("threads an applied Team ID filter into the query", async () => {
|
||||
renderTable();
|
||||
openFilters();
|
||||
|
||||
fireEvent.change(await screen.findByPlaceholderText(/Enter team ID/), { target: { value: "team-xyz" } });
|
||||
fireEvent.click(screen.getByTestId("filter-drawer-apply"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUseTeamsTable).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ teamID: "team-xyz" }));
|
||||
});
|
||||
});
|
||||
|
||||
it("threads the toolbar search into the search param", async () => {
|
||||
renderTable();
|
||||
fireEvent.change(screen.getByTestId("datatable-search"), { target: { value: "platform" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUseTeamsTable).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ search: "platform" }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("non-admin scoping", () => {
|
||||
it("scopes the list to the current user when the role is not an admin role", () => {
|
||||
renderTable({ userRole: "Internal User", userID: "user-42" });
|
||||
expect(lastOptions()).toMatchObject({ userID: "user-42" });
|
||||
});
|
||||
|
||||
it("does not scope by user for the Admin role", () => {
|
||||
renderTable({ userRole: "Admin", userID: "admin-1" });
|
||||
expect(lastOptions()).toMatchObject({ userID: undefined });
|
||||
});
|
||||
});
|
||||
|
||||
describe("row actions", () => {
|
||||
it("opens the team detail when the team cell is clicked", () => {
|
||||
const onSelectTeam = vi.fn();
|
||||
renderTable({ onSelectTeam });
|
||||
|
||||
fireEvent.click(screen.getByText("Acme Team"));
|
||||
expect(onSelectTeam).toHaveBeenCalledWith(expect.objectContaining({ team_id: "team-1" }));
|
||||
});
|
||||
|
||||
it("offers Edit and Delete to an Admin and wires them to the callbacks", async () => {
|
||||
const onEditTeam = vi.fn();
|
||||
const onDeleteTeam = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderTable({ userRole: "Admin", onEditTeam, onDeleteTeam });
|
||||
|
||||
await user.click(screen.getByTestId("team-actions-team-1"));
|
||||
|
||||
await user.click(await screen.findByText("Edit team"));
|
||||
expect(onEditTeam).toHaveBeenCalledWith(expect.objectContaining({ team_id: "team-1" }));
|
||||
|
||||
await user.click(screen.getByTestId("team-actions-team-1"));
|
||||
await user.click(await screen.findByText("Delete team"));
|
||||
expect(onDeleteTeam).toHaveBeenCalledWith(expect.objectContaining({ team_id: "team-1" }));
|
||||
});
|
||||
|
||||
it("hides Edit and Delete from a non-admin, leaving only Copy team ID", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTable({ userRole: "Internal User" });
|
||||
|
||||
await user.click(screen.getByTestId("team-actions-team-1"));
|
||||
|
||||
expect(await screen.findByText("Copy team ID")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Edit team")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Delete team")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("pagination total comes from the query response", () => {
|
||||
it("shows the total count and page count from the response", async () => {
|
||||
mockUseTeamsTable.mockReturnValue(teamsResult([mockTeam], { total: 137, total_pages: 3 }));
|
||||
renderTable();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-50 of 137");
|
||||
expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 1 of 3");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("refresh control", () => {
|
||||
it("calls refetch when clicked", () => {
|
||||
const refetch = vi.fn();
|
||||
mockUseTeamsTable.mockReturnValue(teamsResult([mockTeam], {}, { refetch }));
|
||||
renderTable();
|
||||
|
||||
fireEvent.click(screen.getByTestId("datatable-refresh"));
|
||||
expect(refetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps rows visible but disables refresh while a background fetch is in flight", () => {
|
||||
mockUseTeamsTable.mockReturnValue(teamsResult([mockTeam], {}, { isFetching: true }));
|
||||
renderTable();
|
||||
|
||||
expect(screen.getByTestId("datatable-refresh")).toBeDisabled();
|
||||
expect(screen.getByText("Acme Team")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("column rendering details", () => {
|
||||
it("shows the organization alias when the id resolves, and the raw id when it does not", async () => {
|
||||
mockUseTeamsTable.mockReturnValue(
|
||||
teamsResult([
|
||||
{ ...mockTeam, team_id: "a", organization_id: "org-1" },
|
||||
{ ...mockTeam, team_id: "b", team_alias: "Orphan Team", organization_id: "org-unknown" },
|
||||
]),
|
||||
);
|
||||
renderTable();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Organization")).toBeInTheDocument();
|
||||
expect(screen.getByText("org-unknown")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders an em dash for a team with no organization", () => {
|
||||
mockUseTeamsTable.mockReturnValue(teamsResult([{ ...mockTeam, organization_id: null as unknown as string }]));
|
||||
renderTable();
|
||||
expect(screen.getByText("—")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to keys.length when keys_count is absent", () => {
|
||||
mockUseTeamsTable.mockReturnValue(
|
||||
teamsResult([
|
||||
{
|
||||
...mockTeam,
|
||||
keys_count: undefined,
|
||||
keys: [{ token: "t1" }, { token: "t2" }] as unknown as Team["keys"],
|
||||
},
|
||||
]),
|
||||
);
|
||||
renderTable();
|
||||
expect(screen.getByTitle("2 keys")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hidden-by-default columns", () => {
|
||||
it("hides Members, Models, Rate Limits, and Updated until toggled on", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTable();
|
||||
|
||||
expect(screen.queryByText("Rate Limits")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Columns" }));
|
||||
const menu = await screen.findByRole("menu");
|
||||
expect(within(menu).getByText("Rate Limits")).toBeInTheDocument();
|
||||
expect(within(menu).getByText("Updated")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
201
ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.tsx
Normal file
201
ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.tsx
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
"use client";
|
||||
|
||||
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
|
||||
import { useTeamsTable } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFilterDrawer,
|
||||
DataTableFilterField,
|
||||
DataTableToolbar,
|
||||
} from "@/components/shared/DataTable";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
|
||||
import { ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import { Team } from "../key_team_helpers/key_list";
|
||||
import { getTeamTableColumns, TEAM_TABLE_HIDDEN_COLUMNS } from "./teamTableColumns";
|
||||
|
||||
interface TeamsTableProps {
|
||||
userRole: string | null;
|
||||
userID: string | null;
|
||||
onSelectTeam: (team: Team) => void;
|
||||
onEditTeam: (team: Team) => void;
|
||||
onDeleteTeam: (team: Team) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }];
|
||||
|
||||
const toSortOrder = (sorting: SortingState): "asc" | "desc" | undefined => {
|
||||
const active = sorting[0];
|
||||
if (!active) return undefined;
|
||||
return active.desc ? "desc" : "asc";
|
||||
};
|
||||
|
||||
const FILTER_LABELS: Record<string, string> = {
|
||||
org_id: "Organization",
|
||||
alias: "Team alias",
|
||||
team_id: "Team ID",
|
||||
};
|
||||
|
||||
export function TeamsTable({ userRole, userID, onSelectTeam, onEditTeam, onDeleteTeam }: TeamsTableProps) {
|
||||
const { data: fetchedOrganizations } = useOrganizations();
|
||||
const organizations = useMemo(() => fetchedOrganizations ?? [], [fetchedOrganizations]);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
|
||||
const [tablePagination, setTablePagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 50 });
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [searchQuery] = useDebouncedValue(searchInput, { wait: DEBOUNCE_WAIT_MS });
|
||||
|
||||
const getFilterValue = useCallback(
|
||||
(columnId: string): string | undefined => {
|
||||
const entry = columnFilters.find((filter) => filter.id === columnId);
|
||||
return typeof entry?.value === "string" && entry.value.trim() ? entry.value.trim() : undefined;
|
||||
},
|
||||
[columnFilters],
|
||||
);
|
||||
|
||||
const isAdminView = userRole === "Admin" || userRole === "Admin Viewer";
|
||||
|
||||
const teamListOptions = {
|
||||
organizationID: getFilterValue("org_id"),
|
||||
team_alias: getFilterValue("alias"),
|
||||
teamID: getFilterValue("team_id"),
|
||||
search: searchQuery.trim() || undefined,
|
||||
userID: isAdminView ? undefined : userID ?? undefined,
|
||||
sortBy: sorting[0]?.id,
|
||||
sortOrder: toSortOrder(sorting),
|
||||
};
|
||||
|
||||
const {
|
||||
data: teamsResponse,
|
||||
isPending: isLoading,
|
||||
isFetching,
|
||||
refetch,
|
||||
} = useTeamsTable(tablePagination.pageIndex + 1, tablePagination.pageSize, teamListOptions);
|
||||
|
||||
const teamList = useMemo<Team[]>(() => teamsResponse?.teams ?? [], [teamsResponse]);
|
||||
const rowCount = teamsResponse?.total ?? 0;
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchInput(value);
|
||||
setTablePagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
}, []);
|
||||
|
||||
const handleSortingChange = useCallback<OnChangeFn<SortingState>>((updaterOrValue) => {
|
||||
setSorting(updaterOrValue);
|
||||
setTablePagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
}, []);
|
||||
|
||||
const handleColumnFiltersChange = useCallback<OnChangeFn<ColumnFiltersState>>((updaterOrValue) => {
|
||||
setColumnFilters(updaterOrValue);
|
||||
setTablePagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
}, []);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const columnDeps = { organizations, userRole, onSelectTeam, onEditTeam, onDeleteTeam };
|
||||
return getTeamTableColumns(columnDeps);
|
||||
}, [organizations, userRole, onSelectTeam, onEditTeam, onDeleteTeam]);
|
||||
|
||||
const orgOptions = useMemo(
|
||||
() =>
|
||||
organizations
|
||||
.filter((org) => org.organization_id)
|
||||
.map((org) => {
|
||||
const id = org.organization_id as string;
|
||||
return { label: org.organization_alias || id, value: id, sublabel: org.organization_alias ? id : undefined };
|
||||
}),
|
||||
[organizations],
|
||||
);
|
||||
|
||||
const formatFilterValue = useCallback(
|
||||
(columnId: string, value: unknown): string => {
|
||||
const raw = String(value);
|
||||
if (columnId === "org_id") {
|
||||
return organizations.find((org) => org.organization_id === raw)?.organization_alias || raw;
|
||||
}
|
||||
return raw;
|
||||
},
|
||||
[organizations],
|
||||
);
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
data={teamList}
|
||||
columns={columns}
|
||||
getRowId={(row) => row.team_id}
|
||||
defaultColumnVisibility={TEAM_TABLE_HIDDEN_COLUMNS}
|
||||
sortingMode="server"
|
||||
sorting={sorting}
|
||||
onSortingChange={handleSortingChange}
|
||||
paginationMode="server"
|
||||
pagination={tablePagination}
|
||||
onPaginationChange={setTablePagination}
|
||||
rowCount={rowCount}
|
||||
filterMode="server"
|
||||
columnFilters={columnFilters}
|
||||
onColumnFiltersChange={handleColumnFiltersChange}
|
||||
enableColumnResizing
|
||||
columnResizeMode="onChange"
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading teams..."
|
||||
noDataMessage="No teams found"
|
||||
maxBodyHeight="calc(75vh - 210px)"
|
||||
size="compact"
|
||||
toolbar={(table) => (
|
||||
<>
|
||||
<DataTableToolbar
|
||||
table={table}
|
||||
searchValue={searchInput}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="Search teams by name or ID…"
|
||||
onRefresh={() => refetch?.()}
|
||||
isRefreshing={isFetching}
|
||||
onOpenFilters={() => setFiltersOpen(true)}
|
||||
filterLabels={FILTER_LABELS}
|
||||
formatFilterValue={formatFilterValue}
|
||||
/>
|
||||
<DataTableFilterDrawer
|
||||
table={table}
|
||||
open={filtersOpen}
|
||||
onOpenChange={setFiltersOpen}
|
||||
title="Filters"
|
||||
description="Narrow down your teams"
|
||||
>
|
||||
{({ get, set }) => (
|
||||
<>
|
||||
<DataTableFilterField label="Organization">
|
||||
<SearchSelect
|
||||
options={orgOptions}
|
||||
value={(get("org_id") as string) || undefined}
|
||||
onValueChange={(value) => set("org_id", value)}
|
||||
placeholder="Select an organization…"
|
||||
emptyText="No organizations found"
|
||||
/>
|
||||
</DataTableFilterField>
|
||||
<DataTableFilterField label="Team alias">
|
||||
<Input
|
||||
value={(get("alias") as string) ?? ""}
|
||||
onChange={(event) => set("alias", event.target.value)}
|
||||
placeholder="Enter team alias…"
|
||||
/>
|
||||
</DataTableFilterField>
|
||||
<DataTableFilterField label="Team ID">
|
||||
<Input
|
||||
value={(get("team_id") as string) ?? ""}
|
||||
onChange={(event) => set("team_id", event.target.value)}
|
||||
placeholder="Enter team ID…"
|
||||
/>
|
||||
</DataTableFilterField>
|
||||
</>
|
||||
)}
|
||||
</DataTableFilterDrawer>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,287 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Copy, KeyRound, Layers, MoreHorizontal, Pencil, Trash2, Users } from "lucide-react";
|
||||
|
||||
import { DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { DateCell, IdentityCell, SpendBudgetCell } from "@/components/shared/table_cells";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { copyToClipboard, formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
|
||||
import { Team } from "../key_team_helpers/key_list";
|
||||
import { Organization } from "../networking";
|
||||
|
||||
interface ResourceTone {
|
||||
icon: typeof Users;
|
||||
className: string;
|
||||
}
|
||||
|
||||
const RESOURCE_TONES: Record<"members" | "models" | "keys", ResourceTone> = {
|
||||
members: { icon: Users, className: "bg-violet-50 text-violet-700 ring-violet-600/20" },
|
||||
models: { icon: Layers, className: "bg-sky-50 text-sky-700 ring-sky-600/20" },
|
||||
keys: { icon: KeyRound, className: "bg-emerald-50 text-emerald-700 ring-emerald-600/20" },
|
||||
};
|
||||
|
||||
const teamMemberCount = (team: Team): number => team.members_count ?? team.members_with_roles?.length ?? 0;
|
||||
const teamModelCount = (team: Team): number => team.models?.length ?? 0;
|
||||
const teamKeyCount = (team: Team): number => team.keys_count ?? team.keys?.length ?? 0;
|
||||
|
||||
function ResourcesCell({ team }: { team: Team }) {
|
||||
const items = [
|
||||
{ key: "members" as const, label: "members", count: teamMemberCount(team) },
|
||||
{ key: "models" as const, label: "models", count: teamModelCount(team) },
|
||||
{ key: "keys" as const, label: "keys", count: teamKeyCount(team) },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{items.map((item) => {
|
||||
const tone = RESOURCE_TONES[item.key];
|
||||
const Icon = tone.icon;
|
||||
return (
|
||||
<span
|
||||
key={item.key}
|
||||
title={`${item.count} ${item.label}`}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset [&_svg]:size-3.5",
|
||||
tone.className,
|
||||
)}
|
||||
>
|
||||
<Icon />
|
||||
<span className="tabular-nums">{item.count}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RateLimitLine({ label, value }: { label: string; value: number | null }) {
|
||||
return (
|
||||
<div>
|
||||
<span className="text-[10px] font-semibold text-muted-foreground">{label} </span>
|
||||
<span className="tabular-nums">{value != null ? formatNumberWithCommas(value) : "Unlimited"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TeamRowActionsProps {
|
||||
team: Team;
|
||||
canManage: boolean;
|
||||
onEditTeam: (team: Team) => void;
|
||||
onDeleteTeam: (team: Team) => void;
|
||||
}
|
||||
|
||||
function TeamRowActions({ team, canManage, onEditTeam, onDeleteTeam }: TeamRowActionsProps) {
|
||||
const handleCopy = () => {
|
||||
void copyToClipboard(team.team_id, "Team ID copied");
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label="Open team actions"
|
||||
data-testid={`team-actions-${team.team_id}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
{canManage && (
|
||||
<DropdownMenuItem onClick={() => onEditTeam(team)} data-testid="team-action-edit">
|
||||
<Pencil />
|
||||
Edit team
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={handleCopy} data-testid="team-action-copy">
|
||||
<Copy />
|
||||
Copy team ID
|
||||
</DropdownMenuItem>
|
||||
{canManage && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onClick={() => onDeleteTeam(team)} data-testid="team-action-delete">
|
||||
<Trash2 />
|
||||
Delete team
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
interface TeamTableColumnsDeps {
|
||||
organizations: Organization[];
|
||||
userRole: string | null;
|
||||
onSelectTeam: (team: Team) => void;
|
||||
onEditTeam: (team: Team) => void;
|
||||
onDeleteTeam: (team: Team) => void;
|
||||
}
|
||||
|
||||
export const getTeamTableColumns = ({
|
||||
organizations,
|
||||
userRole,
|
||||
onSelectTeam,
|
||||
onEditTeam,
|
||||
onDeleteTeam,
|
||||
}: TeamTableColumnsDeps): ColumnDef<Team>[] => {
|
||||
const canManage = userRole === "Admin";
|
||||
|
||||
return [
|
||||
{
|
||||
id: "team_alias",
|
||||
accessorKey: "team_alias",
|
||||
meta: {
|
||||
title: "Team",
|
||||
renderSkeleton: () => (
|
||||
<div className="flex flex-col gap-2 py-1">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3.5 w-24 opacity-65" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Team" variant="header-cycle" />,
|
||||
size: 260,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const team = row.original;
|
||||
const hasAlias = Boolean(team.team_alias);
|
||||
return (
|
||||
<IdentityCell
|
||||
title={team.team_alias || team.team_id}
|
||||
subtitle={hasAlias ? team.team_id : undefined}
|
||||
onClick={() => onSelectTeam(team)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "organization_alias",
|
||||
accessorKey: "organization_id",
|
||||
meta: { title: "Organization" },
|
||||
header: "Organization",
|
||||
size: 160,
|
||||
enableSorting: false,
|
||||
cell: (info) => {
|
||||
const orgId = info.getValue() as string | null;
|
||||
if (!orgId) return <span className="text-muted-foreground">—</span>;
|
||||
const org = organizations.find((o) => o.organization_id === orgId);
|
||||
const displayValue = org?.organization_alias || orgId;
|
||||
const width = info.cell.column.getSize();
|
||||
return (
|
||||
<span className="block truncate text-sm" style={{ maxWidth: width }} title={displayValue}>
|
||||
{displayValue}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "resources",
|
||||
meta: {
|
||||
title: "Resources",
|
||||
renderSkeleton: () => (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Skeleton className="h-6 w-12 rounded-md" />
|
||||
<Skeleton className="h-6 w-12 rounded-md" />
|
||||
<Skeleton className="h-6 w-12 rounded-md opacity-65" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
header: "Resources",
|
||||
size: 210,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <ResourcesCell team={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "spend",
|
||||
accessorKey: "spend",
|
||||
meta: { title: "Spend / Budget", skeleton: "meter" },
|
||||
header: "Spend / Budget",
|
||||
size: 200,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <SpendBudgetCell spend={row.original.spend} maxBudget={row.original.max_budget} />,
|
||||
},
|
||||
{
|
||||
id: "created_at",
|
||||
accessorKey: "created_at",
|
||||
meta: { title: "Created" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Created" variant="header-cycle" />,
|
||||
size: 130,
|
||||
enableSorting: true,
|
||||
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" />,
|
||||
},
|
||||
{
|
||||
id: "members",
|
||||
meta: { title: "Members" },
|
||||
header: "Members",
|
||||
size: 110,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <span className="text-sm tabular-nums">{teamMemberCount(row.original)}</span>,
|
||||
},
|
||||
{
|
||||
id: "models",
|
||||
meta: { title: "Models" },
|
||||
header: "Models",
|
||||
size: 100,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <span className="text-sm tabular-nums">{teamModelCount(row.original)}</span>,
|
||||
},
|
||||
{
|
||||
id: "rate_limits",
|
||||
meta: { title: "Rate Limits", skeleton: "twoLine" },
|
||||
header: "Rate Limits",
|
||||
size: 140,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="text-xs leading-tight">
|
||||
<RateLimitLine label="TPM" value={row.original.tpm_limit} />
|
||||
<RateLimitLine label="RPM" value={row.original.rpm_limit} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "updated_at",
|
||||
accessorKey: "updated_at",
|
||||
meta: { title: "Updated" },
|
||||
header: "Updated",
|
||||
size: 130,
|
||||
enableSorting: false,
|
||||
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" fallback="Never" />,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
meta: { className: "text-right", headerClassName: "text-right" },
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
size: 60,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end">
|
||||
<TeamRowActions
|
||||
team={row.original}
|
||||
canManage={canManage}
|
||||
onEditTeam={onEditTeam}
|
||||
onDeleteTeam={onDeleteTeam}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const TEAM_TABLE_HIDDEN_COLUMNS: Record<string, boolean> = {
|
||||
members: false,
|
||||
models: false,
|
||||
rate_limits: false,
|
||||
updated_at: false,
|
||||
};
|
||||
|
|
@ -16,6 +16,8 @@ vi.mock("@tanstack/react-pacer/debouncer", async () => {
|
|||
const [value, setValue] = React.useState(initial);
|
||||
return [value, setValue, { cancel: vi.fn(), flush: vi.fn() }];
|
||||
},
|
||||
useDebouncedCallback: (fn: (...args: unknown[]) => void) => fn,
|
||||
useDebouncer: (fn: (...args: unknown[]) => void) => ({ maybeExecute: fn, cancel: vi.fn(), flush: vi.fn() }),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -63,7 +65,7 @@ const mockKey: KeyResponse = {
|
|||
key_alias: "Test Key Alias",
|
||||
spend: 5.5,
|
||||
max_budget: 100,
|
||||
expires: "2024-12-31T23:59:59Z",
|
||||
expires: "2999-12-31T23:59:59Z",
|
||||
models: ["gpt-3.5-turbo", "gpt-4"],
|
||||
aliases: {},
|
||||
config: {},
|
||||
|
|
@ -154,6 +156,8 @@ const keysResult = (keys: KeyResponse[], data: Partial<KeysResponse> = {}, extra
|
|||
...extra,
|
||||
}) as any;
|
||||
|
||||
const openFilters = () => fireEvent.click(screen.getByRole("button", { name: "Filters" }));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
|
|
@ -170,6 +174,12 @@ it("should render VirtualKeysTable component", () => {
|
|||
expect(screen.getByText("Test Key Alias")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the page header with the create-key action slot", () => {
|
||||
renderWithProviders(<VirtualKeysTable headerActions={<button>Create New Key</button>} />);
|
||||
expect(screen.getByRole("heading", { name: "Virtual Keys" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Create New Key" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display key information correctly", async () => {
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
|
|
@ -177,6 +187,7 @@ it("should display key information correctly", async () => {
|
|||
expect(screen.getByText("Test Key Alias")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Team")).toBeInTheDocument();
|
||||
expect(screen.getByText("$5.5000")).toBeInTheDocument();
|
||||
expect(screen.getByText("of $100")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -188,14 +199,49 @@ it("should display user email correctly", async () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("should show loading message only on initial load (isPending)", () => {
|
||||
it("shows the user alias over the email in the visible cell when both exist", async () => {
|
||||
mockUseKeys.mockReturnValue(
|
||||
keysResult([{ ...mockKey, user: { user_id: "user-1", user_email: "user@example.com", user_alias: "The User" } }]),
|
||||
);
|
||||
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
const row = (await screen.findByText("Test Key Alias")).closest("tr") as HTMLElement;
|
||||
expect(within(row).getByText("The User")).toBeInTheDocument();
|
||||
expect(within(row).queryByText("user@example.com")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows created_by_user alias over email in the Created By column when it is enabled", async () => {
|
||||
mockUseKeys.mockReturnValue(
|
||||
keysResult([
|
||||
{
|
||||
...mockKey,
|
||||
created_by: "some-uuid",
|
||||
created_by_user: { user_id: "some-uuid", user_email: "creator@example.com", user_alias: "The Creator" },
|
||||
},
|
||||
]),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
// Created By is hidden by default; turn it on via the Columns menu.
|
||||
await user.click(screen.getByRole("button", { name: "Columns" }));
|
||||
await user.click(await screen.findByText("Created By"));
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
const row = (await screen.findByText("Test Key Alias")).closest("tr") as HTMLElement;
|
||||
expect(within(row).getByText("The Creator")).toBeInTheDocument();
|
||||
expect(within(row).queryByText("creator@example.com")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show a loading state on the initial load and hide the data", () => {
|
||||
mockUseKeys.mockReturnValue(keysResult([], {}, { data: null, isPending: true, isFetching: true }));
|
||||
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
expect(screen.getByText("🚅 Loading keys...")).toBeInTheDocument();
|
||||
expect(screen.getByText("Loading keys...")).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText("Test Key Alias")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Test Team")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show 'No keys found' message when the key list is empty", () => {
|
||||
|
|
@ -206,61 +252,98 @@ it("should show 'No keys found' message when the key list is empty", () => {
|
|||
expect(screen.getByText("No keys found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle models with more than 3 entries to trigger expansion UI", () => {
|
||||
it("collapses models beyond the visible limit into a '+N more' badge", () => {
|
||||
mockUseKeys.mockReturnValue(
|
||||
keysResult([{ ...mockKey, models: ["gpt-3.5-turbo", "gpt-4", "gpt-4-turbo", "claude-3", "claude-3-5-sonnet"] }]),
|
||||
);
|
||||
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
expect(screen.getByText("Test Key Alias")).toBeInTheDocument();
|
||||
expect(screen.getByText("+2 more")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render table headers correctly", () => {
|
||||
it("should render the redesigned table headers", () => {
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
expect(screen.getByText("Key ID")).toBeInTheDocument();
|
||||
expect(screen.getByText("Key Alias")).toBeInTheDocument();
|
||||
expect(screen.getByText("Key")).toBeInTheDocument();
|
||||
expect(screen.getByText("Team")).toBeInTheDocument();
|
||||
expect(screen.getByText("Models")).toBeInTheDocument();
|
||||
expect(screen.getByText("Spend (USD)")).toBeInTheDocument();
|
||||
expect(screen.getByText("Spend", { selector: "[data-sort-field='spend']" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Budget", { selector: "[data-sort-field='max_budget']" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle column resizing hover events", () => {
|
||||
it("sorts by the backend key_alias field (not the column label) when the Key header is clicked", async () => {
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
const headerCell = document.querySelector("[data-header-id]") as HTMLElement;
|
||||
expect(headerCell).toBeInTheDocument();
|
||||
const keyHeader = screen.getByText("Key").closest("button") as HTMLElement;
|
||||
fireEvent.click(keyHeader);
|
||||
|
||||
const resizer = headerCell?.querySelector(".resizer") as HTMLElement;
|
||||
expect(resizer).toBeInTheDocument();
|
||||
expect(resizer.style.opacity).toBe("0");
|
||||
|
||||
fireEvent.mouseEnter(headerCell);
|
||||
expect(resizer.style.opacity).toBe("0.5");
|
||||
|
||||
fireEvent.mouseLeave(headerCell);
|
||||
expect(resizer.style.opacity).toBe("0");
|
||||
await waitFor(() => {
|
||||
expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ sortBy: "key_alias" }));
|
||||
});
|
||||
});
|
||||
|
||||
it("should open KeyInfoView when clicking on a key ID button", async () => {
|
||||
it("sorts by the backend max_budget field when 'Budget descending' is chosen from the Spend / Budget menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
await user.click(screen.getByTestId("sort-trigger-spend"));
|
||||
await user.click(await screen.findByText("Budget descending"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUseKeys).toHaveBeenLastCalledWith(
|
||||
1,
|
||||
50,
|
||||
expect.objectContaining({ sortBy: "max_budget", sortOrder: "desc" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("emphasizes the active field in the Spend / Budget header so the sorted column reads without opening the menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
await user.click(screen.getByTestId("sort-trigger-spend"));
|
||||
await user.click(await screen.findByText("Budget descending"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Budget", { selector: "[data-sort-field='max_budget']" }).className).toContain(
|
||||
"font-semibold",
|
||||
);
|
||||
});
|
||||
expect(screen.getByText("Spend", { selector: "[data-sort-field='spend']" }).className).toContain(
|
||||
"text-muted-foreground",
|
||||
);
|
||||
});
|
||||
|
||||
it("sorts by spend ascending when 'Spend ascending' is chosen from the Spend / Budget menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
await user.click(screen.getByTestId("sort-trigger-spend"));
|
||||
await user.click(await screen.findByText("Spend ascending"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ sortBy: "spend", sortOrder: "asc" }));
|
||||
});
|
||||
});
|
||||
|
||||
it("should open KeyInfoView when clicking the key cell", async () => {
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Key Alias")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Showing.*results/)).toBeInTheDocument();
|
||||
expect(screen.getByTestId("pagination-range")).toBeInTheDocument();
|
||||
|
||||
const keyIdButton = screen.getByText("sk-1234567890abcdef");
|
||||
fireEvent.click(keyIdButton);
|
||||
fireEvent.click(screen.getByText("Test Key Alias"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Back to Keys")).toBeInTheDocument();
|
||||
expect(screen.getByText("Created At")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText(/Showing.*results/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("pagination-range")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display 'Default Proxy Admin' for user_id when value is 'default_user_id'", async () => {
|
||||
|
|
@ -282,44 +365,6 @@ it("should display 'Default Proxy Admin' for user_id when value is 'default_user
|
|||
});
|
||||
});
|
||||
|
||||
it("should display created_by_user email in 'Created By' column when available", async () => {
|
||||
mockUseKeys.mockReturnValue(
|
||||
keysResult([
|
||||
{
|
||||
...mockKey,
|
||||
created_by: "some-uuid-1234",
|
||||
created_by_user: { user_id: "some-uuid-1234", user_email: "creator@example.com", user_alias: null },
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("creator@example.com")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display created_by_user alias over email when both are available", async () => {
|
||||
mockUseKeys.mockReturnValue(
|
||||
keysResult([
|
||||
{
|
||||
...mockKey,
|
||||
created_by: "some-uuid-1234",
|
||||
created_by_user: { user_id: "some-uuid-1234", user_email: "creator@example.com", user_alias: "The Creator" },
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
// Scope to the key's row so we assert the visible cell value: the hover popover that
|
||||
// also holds the email is portaled out of the row, not the displayed "Created By" text.
|
||||
const row = (await screen.findByText("Test Key Alias")).closest("tr") as HTMLElement;
|
||||
expect(within(row).getByText("The Creator")).toBeInTheDocument();
|
||||
expect(within(row).queryByText("creator@example.com")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render table without crashing when models is null", async () => {
|
||||
mockUseKeys.mockReturnValue(keysResult([{ ...mockKey, models: null as unknown as string[] }]));
|
||||
|
||||
|
|
@ -327,6 +372,7 @@ it("should render table without crashing when models is null", async () => {
|
|||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Key Alias")).toBeInTheDocument();
|
||||
expect(screen.getByText("All Proxy Models")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -341,13 +387,14 @@ it("should display 'Unknown' for last_active when value is null", async () => {
|
|||
});
|
||||
|
||||
describe("server-side filtering – the LIT-4080 regression guard", () => {
|
||||
it("threads an active User ID filter into the useKeys query so any refetch keeps it", async () => {
|
||||
it("threads an applied User ID filter into the useKeys query so any refetch keeps it", async () => {
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Filters" }));
|
||||
openFilters();
|
||||
|
||||
const userIdInput = await screen.findByPlaceholderText("Enter User ID...");
|
||||
const userIdInput = await screen.findByPlaceholderText(/Enter User ID/);
|
||||
fireEvent.change(userIdInput, { target: { value: "user-42" } });
|
||||
fireEvent.click(screen.getByTestId("filter-drawer-apply"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ userID: "user-42" }));
|
||||
|
|
@ -361,18 +408,19 @@ describe("server-side filtering – the LIT-4080 regression guard", () => {
|
|||
expect(lastCall[2] ?? {}).toMatchObject({ userID: undefined, teamID: undefined, keyHash: undefined });
|
||||
});
|
||||
|
||||
it("drops the filter from the useKeys query when Reset Filters is clicked", async () => {
|
||||
it("drops the filter from the useKeys query when it is cleared", async () => {
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Filters" }));
|
||||
const userIdInput = await screen.findByPlaceholderText("Enter User ID...");
|
||||
openFilters();
|
||||
const userIdInput = await screen.findByPlaceholderText(/Enter User ID/);
|
||||
fireEvent.change(userIdInput, { target: { value: "user-42" } });
|
||||
fireEvent.click(screen.getByTestId("filter-drawer-apply"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ userID: "user-42" }));
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Reset Filters" }));
|
||||
fireEvent.click(screen.getByTestId("datatable-clear-filters"));
|
||||
|
||||
await waitFor(() => {
|
||||
const lastCall = mockUseKeys.mock.calls[mockUseKeys.mock.calls.length - 1];
|
||||
|
|
@ -388,8 +436,8 @@ describe("pagination display – total count comes from useKeys", () => {
|
|||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Showing 1 - 50 of 509 results")).toBeInTheDocument();
|
||||
expect(screen.getByText("Page 1 of 11")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-50 of 509");
|
||||
expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 1 of 11");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -399,57 +447,44 @@ describe("pagination display – total count comes from useKeys", () => {
|
|||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Showing 1 - 1 of 1 results")).toBeInTheDocument();
|
||||
expect(screen.getByText("Page 1 of 1")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-1 of 1");
|
||||
expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 1 of 1");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("refetch button", () => {
|
||||
it("should show Fetch button in normal state", () => {
|
||||
describe("refresh button", () => {
|
||||
it("renders an enabled refresh control in the normal state", () => {
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
const fetchButton = screen.getByTitle("Fetch data");
|
||||
expect(fetchButton).toBeInTheDocument();
|
||||
expect(fetchButton).not.toBeDisabled();
|
||||
expect(screen.getByText("Fetch")).toBeInTheDocument();
|
||||
const refresh = screen.getByTestId("datatable-refresh");
|
||||
expect(refresh).toBeInTheDocument();
|
||||
expect(refresh).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("should show Fetching state and keep table data visible during refetch", () => {
|
||||
it("disables the refresh control while a fetch is in flight but keeps data visible", () => {
|
||||
mockUseKeys.mockReturnValue(keysResult([mockKey], {}, { isFetching: true }));
|
||||
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
expect(screen.getByText("Fetching")).toBeInTheDocument();
|
||||
expect(screen.getByTitle("Fetch data")).toBeDisabled();
|
||||
expect(screen.getByTestId("datatable-refresh")).toBeDisabled();
|
||||
expect(screen.getByText("Test Key Alias")).toBeInTheDocument();
|
||||
expect(screen.queryByText("🚅 Loading keys...")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call refetch when Fetch button is clicked", () => {
|
||||
it("calls refetch when the refresh control is clicked", () => {
|
||||
const mockRefetch = vi.fn();
|
||||
mockUseKeys.mockReturnValue(keysResult([mockKey], {}, { refetch: mockRefetch }));
|
||||
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
fireEvent.click(screen.getByTitle("Fetch data"));
|
||||
fireEvent.click(screen.getByTestId("datatable-refresh"));
|
||||
|
||||
expect(mockRefetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should show Fetch button enabled on error so user can retry", () => {
|
||||
mockUseKeys.mockReturnValue(keysResult([], {}, { data: null, isError: true }));
|
||||
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
const fetchButton = screen.getByTitle("Fetch data");
|
||||
expect(fetchButton).not.toBeDisabled();
|
||||
expect(screen.getByText("Fetch")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Status column reflects key.blocked / scim_blocked metadata", () => {
|
||||
it("should render Active for a non-blocked key", async () => {
|
||||
describe("Status column reflects blocked / expiry / scim metadata", () => {
|
||||
it("renders Active for a non-blocked, unexpired key", async () => {
|
||||
mockUseKeys.mockReturnValue(keysResult([{ ...mockKey, blocked: false, metadata: {} }]));
|
||||
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
|
@ -459,7 +494,19 @@ describe("Status column reflects key.blocked / scim_blocked metadata", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("should render Blocked when key.blocked is true", async () => {
|
||||
it("renders Expired when the expiry date has passed", async () => {
|
||||
mockUseKeys.mockReturnValue(
|
||||
keysResult([{ ...mockKey, blocked: false, metadata: {}, expires: "2020-01-01T00:00:00Z" }]),
|
||||
);
|
||||
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId(`key-status-${mockKey.token_id}`)).toHaveTextContent("Expired");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders Blocked when key.blocked is true", async () => {
|
||||
mockUseKeys.mockReturnValue(keysResult([{ ...mockKey, blocked: true, metadata: {} }]));
|
||||
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
|
@ -470,7 +517,7 @@ describe("Status column reflects key.blocked / scim_blocked metadata", () => {
|
|||
expect(screen.queryByText(/Blocked by SCIM/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should mark a SCIM-blocked key with the SCIM tooltip reason", async () => {
|
||||
it("marks a SCIM-blocked key with the SCIM tooltip reason", async () => {
|
||||
mockUseKeys.mockReturnValue(keysResult([{ ...mockKey, blocked: true, metadata: { scim_blocked: true } }]));
|
||||
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,364 @@
|
|||
"use client";
|
||||
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Popover, Typography } from "antd";
|
||||
|
||||
import { DataTableMultiSortHeader, DataTableSortHeader, type DataTableSortField } from "@/components/shared/DataTable";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
DateCell,
|
||||
IdCell,
|
||||
IdentityCell,
|
||||
ModelsCell,
|
||||
SpendBudgetCell,
|
||||
StatusBadge,
|
||||
type StatusTone,
|
||||
} from "@/components/shared/table_cells";
|
||||
|
||||
import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag";
|
||||
import { KeyResponse, Team } from "../key_team_helpers/key_list";
|
||||
import { Organization } from "../networking";
|
||||
|
||||
interface KeyStatus {
|
||||
tone: StatusTone;
|
||||
label: string;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
const SPEND_BUDGET_SORT_FIELDS: DataTableSortField[] = [
|
||||
{ id: "spend", label: "Spend" },
|
||||
{ id: "max_budget", label: "Budget" },
|
||||
];
|
||||
|
||||
const getKeyStatus = (key: KeyResponse): KeyStatus => {
|
||||
if (key.blocked === true) {
|
||||
const isScimBlocked = (key.metadata as Record<string, unknown> | null | undefined)?.scim_blocked === true;
|
||||
return {
|
||||
tone: "error",
|
||||
label: "Blocked",
|
||||
tooltip: isScimBlocked
|
||||
? "Blocked by SCIM (external identity provider deactivated or deleted the owning user)."
|
||||
: "Blocked. Requests using this key will be rejected with 401.",
|
||||
};
|
||||
}
|
||||
const expiresAt = key.expires ? Date.parse(key.expires) : Number.NaN;
|
||||
if (!Number.isNaN(expiresAt) && expiresAt < Date.now()) {
|
||||
return { tone: "warning", label: "Expired", tooltip: "This key has passed its expiry date." };
|
||||
}
|
||||
return { tone: "success", label: "Active" };
|
||||
};
|
||||
|
||||
const UserPopoverCell = ({
|
||||
userAlias,
|
||||
userEmail,
|
||||
userId,
|
||||
width,
|
||||
}: {
|
||||
userAlias: string | null;
|
||||
userEmail: string | null;
|
||||
userId: string | null;
|
||||
width: number;
|
||||
}) => {
|
||||
const displayValue = userAlias || userEmail || userId;
|
||||
const isDefaultAdmin = userId === "default_user_id";
|
||||
|
||||
const popoverContent = (
|
||||
<div className="flex flex-col gap-2 text-xs min-w-[200px] max-w-[300px]">
|
||||
{[
|
||||
{ label: "User Alias", value: userAlias },
|
||||
{ label: "User Email", value: userEmail },
|
||||
{ label: "User ID", value: userId },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} className="flex flex-col min-w-0">
|
||||
<span className="text-gray-400">{label}</span>
|
||||
{value ? (
|
||||
<Typography.Text className="font-mono text-xs" ellipsis={{ tooltip: value }} copyable>
|
||||
{value}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<span className="font-mono">-</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isDefaultAdmin && !userAlias && !userEmail) {
|
||||
return (
|
||||
<Popover content={popoverContent} trigger="hover" placement="bottomLeft">
|
||||
<span className="cursor-default">
|
||||
<DefaultProxyAdminTag userId={userId} />
|
||||
</span>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover content={popoverContent} trigger="hover" placement="bottomLeft">
|
||||
<span className="font-mono text-xs truncate block cursor-default" style={{ maxWidth: width, overflow: "hidden" }}>
|
||||
{displayValue || "-"}
|
||||
</span>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const InfoHeader = ({ label, tooltip }: { label: string; tooltip: string }) => (
|
||||
<span className="flex items-center gap-1">
|
||||
{label}
|
||||
<Popover content={tooltip} trigger="hover">
|
||||
<InfoCircleOutlined className="text-gray-400 text-xs cursor-help" />
|
||||
</Popover>
|
||||
</span>
|
||||
);
|
||||
|
||||
interface KeyTableColumnsDeps {
|
||||
allTeams: Team[];
|
||||
organizations: Organization[];
|
||||
onSelectKey: (key: KeyResponse) => void;
|
||||
}
|
||||
|
||||
export const getKeyTableColumns = ({
|
||||
allTeams,
|
||||
organizations,
|
||||
onSelectKey,
|
||||
}: KeyTableColumnsDeps): ColumnDef<KeyResponse>[] => [
|
||||
{
|
||||
id: "key_alias",
|
||||
accessorKey: "key_alias",
|
||||
meta: {
|
||||
title: "Key",
|
||||
renderSkeleton: () => (
|
||||
<div className="flex flex-col gap-1 py-1">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Key" variant="header-cycle" />,
|
||||
size: 260,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const status = getKeyStatus(row.original);
|
||||
return (
|
||||
<IdentityCell
|
||||
title={row.original.key_alias || "-"}
|
||||
subtitle={row.original.key_name}
|
||||
badge={
|
||||
<StatusBadge
|
||||
tone={status.tone}
|
||||
label={status.label}
|
||||
tooltip={status.tooltip}
|
||||
dataTestId={`key-status-${row.original.token_id}`}
|
||||
/>
|
||||
}
|
||||
onClick={() => onSelectKey(row.original)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "token",
|
||||
accessorKey: "token",
|
||||
meta: { title: "Key ID" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Key ID" variant="header-cycle" />,
|
||||
size: 120,
|
||||
enableSorting: true,
|
||||
cell: (info) => <IdCell value={info.getValue() as string | null} onClick={() => onSelectKey(info.row.original)} />,
|
||||
},
|
||||
{
|
||||
id: "team_alias",
|
||||
accessorKey: "team_id",
|
||||
meta: { title: "Team" },
|
||||
header: "Team",
|
||||
size: 120,
|
||||
enableSorting: false,
|
||||
cell: (info) => {
|
||||
const teamId = info.getValue() as string | null;
|
||||
if (!teamId) return "-";
|
||||
const team = allTeams.find((t) => t.team_id === teamId);
|
||||
const displayValue = team?.team_alias || teamId;
|
||||
const width = info.cell.column.getSize();
|
||||
return (
|
||||
<span className="font-mono text-xs truncate block" style={{ maxWidth: width, overflow: "hidden" }}>
|
||||
{displayValue}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "organization_alias",
|
||||
accessorKey: "org_id",
|
||||
meta: { title: "Organization" },
|
||||
header: "Organization",
|
||||
size: 140,
|
||||
enableSorting: false,
|
||||
cell: (info) => {
|
||||
const orgId = info.getValue() as string | null;
|
||||
if (!orgId) return "-";
|
||||
const org = organizations.find((o) => o.organization_id === orgId);
|
||||
const displayValue = org?.organization_alias || orgId;
|
||||
const width = info.cell.column.getSize();
|
||||
return (
|
||||
<span className="font-mono text-xs truncate block" style={{ maxWidth: width, overflow: "hidden" }}>
|
||||
{displayValue}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "user",
|
||||
accessorKey: "user",
|
||||
meta: { title: "User" },
|
||||
header: () => (
|
||||
<InfoHeader label="User" tooltip="Displays the first available value: User Alias, User Email, or User ID." />
|
||||
),
|
||||
size: 160,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const key = row.original;
|
||||
return (
|
||||
<UserPopoverCell
|
||||
userAlias={key.user?.user_alias ?? null}
|
||||
userEmail={key.user?.user_email ?? key.user_email ?? null}
|
||||
userId={key.user_id ?? null}
|
||||
width={160}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "created_at",
|
||||
accessorKey: "created_at",
|
||||
meta: { title: "Created At" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Created At" variant="header-cycle" />,
|
||||
size: 120,
|
||||
enableSorting: true,
|
||||
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" />,
|
||||
},
|
||||
{
|
||||
id: "created_by",
|
||||
accessorKey: "created_by",
|
||||
meta: { title: "Created By" },
|
||||
header: "Created By",
|
||||
size: 160,
|
||||
enableSorting: false,
|
||||
cell: (info) => {
|
||||
const userId = info.getValue() as string | null;
|
||||
if (!userId) return "-";
|
||||
const createdByUser = info.row.original.created_by_user;
|
||||
return (
|
||||
<UserPopoverCell
|
||||
userAlias={createdByUser?.user_alias ?? null}
|
||||
userEmail={createdByUser?.user_email ?? null}
|
||||
userId={userId}
|
||||
width={160}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "updated_at",
|
||||
accessorKey: "updated_at",
|
||||
meta: { title: "Updated At" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Updated At" variant="header-cycle" />,
|
||||
size: 120,
|
||||
enableSorting: true,
|
||||
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" fallback="Never" />,
|
||||
},
|
||||
{
|
||||
id: "last_active",
|
||||
accessorKey: "last_active",
|
||||
meta: { title: "Last Active" },
|
||||
header: () => (
|
||||
<InfoHeader
|
||||
label="Last Active"
|
||||
tooltip="This is a new field and is not backfilled. Only new key usage will update this value."
|
||||
/>
|
||||
),
|
||||
size: 130,
|
||||
enableSorting: false,
|
||||
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" fallback="Unknown" />,
|
||||
},
|
||||
{
|
||||
id: "expires",
|
||||
accessorKey: "expires",
|
||||
meta: { title: "Expires" },
|
||||
header: "Expires",
|
||||
size: 120,
|
||||
enableSorting: false,
|
||||
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" fallback="Never" />,
|
||||
},
|
||||
{
|
||||
id: "spend",
|
||||
accessorKey: "spend",
|
||||
meta: { title: "Spend / Budget", skeleton: "meter" },
|
||||
header: ({ table }) => <DataTableMultiSortHeader table={table} fields={SPEND_BUDGET_SORT_FIELDS} />,
|
||||
size: 180,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const teamId = row.original.team_id;
|
||||
const team = allTeams.find((t) => t.team_id === teamId);
|
||||
return (
|
||||
<SpendBudgetCell
|
||||
spend={row.original.spend}
|
||||
maxBudget={row.original.max_budget}
|
||||
teamMaxBudget={team?.max_budget ?? null}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "budget_reset_at",
|
||||
accessorKey: "budget_reset_at",
|
||||
meta: { title: "Budget Reset" },
|
||||
header: "Budget Reset",
|
||||
size: 130,
|
||||
enableSorting: false,
|
||||
cell: (info) => <DateCell value={info.getValue() as string | null} fallback="Never" />,
|
||||
},
|
||||
{
|
||||
id: "models",
|
||||
accessorKey: "models",
|
||||
meta: { title: "Models", skeleton: "chips" },
|
||||
header: "Models",
|
||||
size: 220,
|
||||
enableSorting: false,
|
||||
cell: (info) => (
|
||||
<ModelsCell
|
||||
models={info.getValue() as string[] | null | undefined}
|
||||
allowedRoutes={info.row.original.allowed_routes}
|
||||
keyType={info.row.original.key_type}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "rate_limits",
|
||||
meta: { title: "Rate Limits" },
|
||||
header: "Rate Limits",
|
||||
size: 140,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const key = row.original;
|
||||
return (
|
||||
<div className="text-xs">
|
||||
<div>TPM: {key.tpm_limit !== null ? key.tpm_limit : "Unlimited"}</div>
|
||||
<div>RPM: {key.rpm_limit !== null ? key.rpm_limit : "Unlimited"}</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const KEY_TABLE_HIDDEN_COLUMNS: Record<string, boolean> = {
|
||||
token: false,
|
||||
organization_alias: false,
|
||||
created_by: false,
|
||||
updated_at: false,
|
||||
expires: false,
|
||||
budget_reset_at: false,
|
||||
rate_limits: false,
|
||||
};
|
||||
|
|
@ -56,4 +56,23 @@ describe("FilterInput", () => {
|
|||
|
||||
expect(input.value).toBe("a");
|
||||
});
|
||||
|
||||
it("should not call onChange when unmounted mid-debounce", () => {
|
||||
const onChange = vi.fn();
|
||||
const { unmount } = render(<FilterInput value="" onChange={onChange} placeholder="Search..." />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Search...");
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(input, { target: { value: "test" } });
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { cx } from "@/lib/cva.config";
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
|
||||
import { Input } from "antd";
|
||||
import debounce from "lodash/debounce";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
interface FilterInputProps {
|
||||
placeholder?: string;
|
||||
|
|
@ -13,8 +14,6 @@ interface FilterInputProps {
|
|||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const DEBOUNCE_DELAY = 300;
|
||||
|
||||
export const FilterInput: React.FC<FilterInputProps> = ({ placeholder, value, onChange, icon: Icon, className }) => {
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
|
||||
|
|
@ -22,22 +21,13 @@ export const FilterInput: React.FC<FilterInputProps> = ({ placeholder, value, on
|
|||
setLocalValue(value);
|
||||
}, [value]);
|
||||
|
||||
const debouncedOnChange = useMemo(() => debounce((val: string) => onChange(val), DEBOUNCE_DELAY), [onChange]);
|
||||
const debouncedOnChange = useDebouncedCallback((val: string) => onChange(val), { wait: DEBOUNCE_WAIT_MS });
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncedOnChange.cancel();
|
||||
};
|
||||
}, [debouncedOnChange]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
setLocalValue(newValue);
|
||||
debouncedOnChange(newValue);
|
||||
},
|
||||
[debouncedOnChange],
|
||||
);
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
setLocalValue(newValue);
|
||||
debouncedOnChange(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import ModelSelector from "./ModelSelector";
|
||||
|
||||
vi.mock("@/components/llm_calls/fetch_models", () => ({
|
||||
fetchAvailableModels: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
const openCustomModelInput = () => {
|
||||
const selector = document.querySelector(".ant-select-selector");
|
||||
expect(selector).toBeTruthy();
|
||||
act(() => {
|
||||
fireEvent.mouseDown(selector!);
|
||||
});
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByText("Enter custom model"));
|
||||
});
|
||||
return screen.getByPlaceholderText("Enter custom model name");
|
||||
};
|
||||
|
||||
describe("ModelSelector custom model debounce", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
});
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not call onChange before the debounce wait elapses", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ModelSelector accessToken="test-token" onChange={onChange} />);
|
||||
|
||||
const input = openCustomModelInput();
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(input, { target: { value: "gpt-4o" } });
|
||||
});
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(499);
|
||||
});
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onChange exactly once with the last typed value after the wait", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ModelSelector accessToken="test-token" onChange={onChange} />);
|
||||
|
||||
const input = openCustomModelInput();
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(input, { target: { value: "g" } });
|
||||
fireEvent.change(input, { target: { value: "gp" } });
|
||||
fireEvent.change(input, { target: { value: "gpt-5.2" } });
|
||||
});
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenCalledWith("gpt-5.2");
|
||||
});
|
||||
|
||||
it("does not call onChange when unmounted mid-wait", () => {
|
||||
const onChange = vi.fn();
|
||||
const { unmount } = render(<ModelSelector accessToken="test-token" onChange={onChange} />);
|
||||
|
||||
const input = openCustomModelInput();
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(input, { target: { value: "gpt-4o" } });
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,9 +1,12 @@
|
|||
import React, { useState, useEffect, useRef } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { TextInput, Text } from "@tremor/react";
|
||||
import { Select } from "antd";
|
||||
import { RobotOutlined } from "@ant-design/icons";
|
||||
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
|
||||
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
|
||||
const MODEL_SELECT_DEBOUNCE_MS = 500;
|
||||
|
||||
interface ModelSelectorProps {
|
||||
accessToken: string;
|
||||
value?: string;
|
||||
|
|
@ -30,7 +33,6 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||
const [selectedModel, setSelectedModel] = useState<string | undefined>(value);
|
||||
const [showCustomModelInput, setShowCustomModelInput] = useState<boolean>(false);
|
||||
const [modelInfo, setModelInfo] = useState<ModelGroup[]>([]);
|
||||
const customModelTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedModel(value);
|
||||
|
|
@ -67,19 +69,13 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
const handleCustomModelChange = (value: string) => {
|
||||
// Using setTimeout to create a simple debounce effect
|
||||
if (customModelTimeout.current) {
|
||||
clearTimeout(customModelTimeout.current);
|
||||
}
|
||||
|
||||
customModelTimeout.current = setTimeout(() => {
|
||||
const debouncedSelect = useDebouncedCallback(
|
||||
(value: string) => {
|
||||
setSelectedModel(value);
|
||||
if (onChange) {
|
||||
onChange(value);
|
||||
}
|
||||
}, 500); // 500ms delay after typing stops
|
||||
};
|
||||
onChange?.(value);
|
||||
},
|
||||
{ wait: MODEL_SELECT_DEBOUNCE_MS },
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
|
@ -109,7 +105,7 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||
<TextInput
|
||||
className="mt-2"
|
||||
placeholder="Enter custom model name"
|
||||
onValueChange={handleCustomModelChange}
|
||||
onValueChange={debouncedSelect}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RouterSettingsFormValue } from "../router_settings/RouterSettingsForm";
|
||||
import RouterSettingsAccordion, { RouterSettingsAccordionValue } from "./RouterSettingsAccordion";
|
||||
|
||||
vi.mock("../networking", () => ({
|
||||
getRouterSettingsCall: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/llm_calls/fetch_models", () => ({
|
||||
fetchAvailableModels: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock("../Settings/RouterSettings/Fallbacks/FallbackSelectionForm", () => ({
|
||||
FallbackSelectionForm: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@tremor/react", () => ({
|
||||
TabGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
TabList: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
Tab: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
TabPanels: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
TabPanel: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../router_settings/RouterSettingsForm", () => ({
|
||||
default: ({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: RouterSettingsFormValue;
|
||||
onChange: (value: RouterSettingsFormValue) => void;
|
||||
}) => (
|
||||
<div>
|
||||
<button onClick={() => onChange({ ...value, selectedStrategy: "least-busy" })}>set-least-busy</button>
|
||||
<button onClick={() => onChange({ ...value, selectedStrategy: "usage-based-routing" })}>set-usage-based</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("RouterSettingsAccordion", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const flushInitialPropagation = async (onChange: ReturnType<typeof vi.fn>) => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
onChange.mockClear();
|
||||
};
|
||||
|
||||
it("debounces propagation and calls onChange once with the last value", async () => {
|
||||
const onChange = vi.fn<(value: RouterSettingsAccordionValue) => void>();
|
||||
render(<RouterSettingsAccordion accessToken="test-token" onChange={onChange} />);
|
||||
await flushInitialPropagation(onChange);
|
||||
|
||||
fireEvent.click(screen.getByText("set-least-busy"));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(50);
|
||||
});
|
||||
fireEvent.click(screen.getByText("set-usage-based"));
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(99);
|
||||
});
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1);
|
||||
});
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange.mock.calls[0][0].router_settings.routing_strategy).toBe("usage-based-routing");
|
||||
});
|
||||
|
||||
it("does not call onChange when unmounted mid-wait", async () => {
|
||||
const onChange = vi.fn<(value: RouterSettingsAccordionValue) => void>();
|
||||
const { unmount } = render(<RouterSettingsAccordion accessToken="test-token" onChange={onChange} />);
|
||||
await flushInitialPropagation(onChange);
|
||||
|
||||
fireEvent.click(screen.getByText("set-least-busy"));
|
||||
unmount();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useEffect, useState, useImperativeHandle, forwardRef, useRef } from "react";
|
||||
import { TabPanel, TabPanels, TabGroup, TabList, Tab } from "@tremor/react";
|
||||
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
|
||||
import { getRouterSettingsCall } from "../networking";
|
||||
import RouterSettingsForm, { RouterSettingsFormValue } from "../router_settings/RouterSettingsForm";
|
||||
import { Fallbacks } from "../Settings/RouterSettings/Fallbacks/AddFallbacks";
|
||||
|
|
@ -35,6 +36,8 @@ export interface RouterSettingsAccordionRef {
|
|||
getValue: () => RouterSettingsAccordionValue;
|
||||
}
|
||||
|
||||
const PROPAGATE_WAIT_MS = 100;
|
||||
|
||||
const RouterSettingsAccordion = forwardRef<RouterSettingsAccordionRef, RouterSettingsAccordionProps>(
|
||||
({ accessToken, value, onChange, modelData }, ref) => {
|
||||
const [formValue, setFormValue] = useState<RouterSettingsFormValue>({
|
||||
|
|
@ -304,21 +307,26 @@ const RouterSettingsAccordion = forwardRef<RouterSettingsAccordionRef, RouterSet
|
|||
};
|
||||
};
|
||||
|
||||
// Update parent when form values change (with debounce to avoid infinite loops)
|
||||
useEffect(() => {
|
||||
if (!onChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
const debouncedPropagate = useDebouncedCallback(
|
||||
() => {
|
||||
if (!onChange) {
|
||||
return;
|
||||
}
|
||||
isInternalUpdateRef.current = true;
|
||||
const finalRouterSettings = buildRouterSettings();
|
||||
onChange({
|
||||
router_settings: finalRouterSettings,
|
||||
});
|
||||
}, 100);
|
||||
},
|
||||
{ wait: PROPAGATE_WAIT_MS },
|
||||
);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
// Update parent when form values change (with debounce to avoid infinite loops)
|
||||
useEffect(() => {
|
||||
if (!onChange) {
|
||||
return;
|
||||
}
|
||||
debouncedPropagate();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [formValue, fallbacks]);
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Select, Typography } from "antd";
|
|||
import { LoadingOutlined } from "@ant-design/icons";
|
||||
import { useDebouncedState } from "@tanstack/react-pacer/debouncer";
|
||||
import { useInfiniteTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
import { Team } from "../key_team_helpers/key_list";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
|
@ -19,7 +20,6 @@ interface TeamDropdownProps {
|
|||
}
|
||||
|
||||
const SCROLL_THRESHOLD = 0.8;
|
||||
const DEBOUNCE_MS = 300;
|
||||
|
||||
const TeamDropdown: React.FC<TeamDropdownProps> = ({
|
||||
value,
|
||||
|
|
@ -31,7 +31,7 @@ const TeamDropdown: React.FC<TeamDropdownProps> = ({
|
|||
}) => {
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useDebouncedState("", {
|
||||
wait: DEBOUNCE_MS,
|
||||
wait: DEBOUNCE_WAIT_MS,
|
||||
});
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteTeams(
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Select, Typography } from "antd";
|
|||
import { LoadingOutlined } from "@ant-design/icons";
|
||||
import { useDebouncedState } from "@tanstack/react-pacer/debouncer";
|
||||
import { useInfiniteTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
import { Team } from "../key_team_helpers/key_list";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
|
@ -17,7 +18,6 @@ interface TeamMultiSelectProps {
|
|||
}
|
||||
|
||||
const SCROLL_THRESHOLD = 0.8;
|
||||
const DEBOUNCE_MS = 300;
|
||||
|
||||
const TeamMultiSelect: React.FC<TeamMultiSelectProps> = ({
|
||||
value = [],
|
||||
|
|
@ -29,7 +29,7 @@ const TeamMultiSelect: React.FC<TeamMultiSelectProps> = ({
|
|||
}) => {
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useDebouncedState("", {
|
||||
wait: DEBOUNCE_MS,
|
||||
wait: DEBOUNCE_WAIT_MS,
|
||||
});
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteTeams(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import UserSearchModal from "./user_search_modal";
|
||||
import { userFilterUICall } from "@/components/networking";
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
userFilterUICall: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
const renderModal = () =>
|
||||
render(<UserSearchModal isVisible onCancel={vi.fn()} onSubmit={vi.fn()} accessToken="sk-test" />);
|
||||
|
||||
const getEmailSearchInput = () => within(screen.getByTestId("member-email-search")).getByRole("combobox");
|
||||
|
||||
describe("UserSearchModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(userFilterUICall).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("debounces the user search and fires exactly once with the last typed value", async () => {
|
||||
renderModal();
|
||||
const input = getEmailSearchInput();
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(input, { target: { value: "a" } });
|
||||
fireEvent.change(input, { target: { value: "ab" } });
|
||||
fireEvent.change(input, { target: { value: "abc" } });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(DEBOUNCE_WAIT_MS - 1);
|
||||
});
|
||||
expect(userFilterUICall).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(userFilterUICall).toHaveBeenCalledTimes(1);
|
||||
const params = vi.mocked(userFilterUICall).mock.calls[0][1];
|
||||
expect(params.get("user_email")).toBe("abc");
|
||||
});
|
||||
|
||||
it("does not fire the search when unmounted mid-wait", () => {
|
||||
const { unmount } = renderModal();
|
||||
const input = getEmailSearchInput();
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(input, { target: { value: "abc" } });
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(DEBOUNCE_WAIT_MS * 2);
|
||||
});
|
||||
|
||||
expect(userFilterUICall).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
import { useState, useCallback } from "react";
|
||||
import { useState } from "react";
|
||||
import { Modal, Form, Button, Select, Tooltip } from "antd";
|
||||
import { UserAddOutlined } from "@ant-design/icons";
|
||||
import debounce from "lodash/debounce";
|
||||
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
|
||||
import { userFilterUICall } from "@/components/networking";
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
interface User {
|
||||
user_id: string;
|
||||
user_email: string;
|
||||
|
|
@ -93,9 +94,9 @@ const UserSearchModal: React.FC<UserSearchModalProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
const debouncedSearch = useCallback(
|
||||
debounce((text: string, fieldName: "user_email" | "user_id") => fetchUsers(text, fieldName), 300),
|
||||
[],
|
||||
const debouncedSearch = useDebouncedCallback(
|
||||
(text: string, fieldName: "user_email" | "user_id") => fetchUsers(text, fieldName),
|
||||
{ wait: DEBOUNCE_WAIT_MS },
|
||||
);
|
||||
|
||||
const handleSearch = (value: string, fieldName: "user_email" | "user_id"): void => {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue